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

Vue3+TypeScript+Django Rest Fram

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

博客网站最重要的是有一个给用户浏览文章的页面,也就是博客网站的前台,用户通过这个页面可以查找文章,浏览文章详情,评论,点赞等。

大家好,我是落霞孤鹜,上一篇我们已经博客的管理后台功能,这一章我们开始搭建博客的前台,实现对博客网站文章的查看,浏览,评论,点赞等功能。我同样按照一个完整的功能,从需求分析到代码编写来阐述如何实现。

一、需求分析

作为一个完整的博客网站,前台是内容呈现的核心部分,大部分的博客搭建文章着重介绍的也是这一部分。这里我们从实际需要出发,整理了如下需求要点:

  1. 首页: 主要展示整个博客网站的文章,一般按照发布时间倒序呈现,展示标题,摘要,浏览量,点赞量,评论量,留言量等内容,提供标签筛选
  2. 文章详情:主要用来展示文章的详情,涵盖文章的所有细节,同时提供文章的章节目录导航、点赞、评论功能。
  3. 文章分类:通过分类呈现文章列表,方便用户通过类型快速查找感兴趣的文章。
  4. 归档:按照年份倒序呈现博客网站的文章列表。
  5. 关于:一般介绍博客的博主情况和博客网站的主题信息。

以上功能也算是一套简单的个人博客网站的核心功能框架。

二、后端接口部分

后端接口部分在上一篇的管理后端中,已经全部实现,这里就不再重复介绍。

三、前端界面部分

前端按照需求,我们从首页,文章详情,文章分类,归档,关于五个部分呈现。这一部分的页面全部放在src/views/client文件中。

首页的功能,实际上是一个文章列表展示,而分类页面是增加了一个分类树的文章列表展示,因此在设计页面的时候,将文章展示列表作为一个组件,这样分类展示可以通过列表和分类两个组件拼装而成。

关于页面可以类比为一篇介绍博客和博主的文章详情页面,因此,可以将文章详情展示作为一个组件,这样可以完成对详情页和关于页的支撑。

3.1 首页

3.1.1 Type

在处理管理后台时,已经在src/types/index.ts文件中定义好了文章相关的interfaceArticle, ArticleArray, ArticleParams

3.1.2 API

在处理管理后台时,已经在src/api/service.ts文件中定义好了方法getArticleList

3.1.3 Component

依据上面的分析,我们需要将文章列表封装成一个组件,因此在src/components下新增文件ArticleList.vue,通过点击文章,在一个新的页面中查看文章详情,代码如下:

<template>
  <ul id="list" class="articles-list">
    <transition-group name="el-fade-in">
      <li v-for="article in articleList" :key="article.id" class="item">
        <a :href="href + article.id" target="_blank">
          <img :data-src="article.cover" alt="文章封面" class="wrap-img img-blur-done"
               data-has-lazy-src="false" src="/src/assets/cover.jpg"/>
        </a>
        <div class="content">
          <a :href="href + article.id" target="_blank">
            <h4 class="title">{{ article.title }}</h4>
            <p class="abstract">{{ article.excerpt }}</p>
          </a>
          <div class="meta">
            <span>查看 {{ article.views }}</span>
            <span>评论 {{ article.comments }}</span>
            <span>赞 {{ article.likes }}</span>
            <router-link v-for="tag in article.tags_info" :key="tag.id"
                         :to="`/articles?tags=${tag.id}&catalog=`">
              <el-tag size="mini">{{ tag.name }}</el-tag>
            </router-link>
            <span v-if="article.created_at" class="time">{{ formatTime(article.created_at) }}</span>
          </div>
        </div>
      </li>
    </transition-group>
  </ul>
</template>
<script lang="ts">
import {timestampToTime} from "../utils";
import {defineComponent, PropType} from "vue";
import {Article} from "../types";

export default defineComponent({
  name: 'ArticleList',
  props: {
    articleList: {
      type: Array as PropType<Array<Article>>,
      default: []
    }
  },
  setup() {
    const formatTime = (value: string | Date): string => {
      return timestampToTime(value, true);
    };
    const href: string = '/article/?id='
    return {
      formatTime,
      href,
    }
  }
})
</script>
<style lang="less" scoped>
.articles-list {
  margin: 0;
  padding: 0;
  list-style: none;

  .title {
    color: #333;
    margin: 7px 0 4px;
    display: inherit;
    font-size: 18px;
    font-weight: 700;
    line-height: 1.5;
  }

  .item > div {
    padding-right: 140px;
  }

  .item .wrap-img {
    position: absolute;
    top: 50%;
    margin-top: -50px;
    right: 0;
    width: 125px;
    height: 100px;
    border-radius: 4px;
    img {
      width: 100%;
      height: 100%;
      border: 1px solid #f0f0f0;
    }
  }

  li {
    line-height: 20px;
    position: relative;
    // width: 100%;
    padding: 15px 0px;
    padding-right: 150px;
    border-bottom: 1px solid #f0f0f0;
    word-wrap: break-word;
    cursor: pointer;

    &:hover {
      .title {
        color: #000;
      }
    }

    .abstract {
      min-height: 30px;
      margin: 0 0 8px;
      font-size: 13px;
      line-height: 24px;
      color: #555;
    }

    .meta {
      padding-right: 0 !important;
      font-size: 12px;
      font-weight: 400;
      line-height: 20px;

      a {
        margin-right: 10px;
        color: #b4b4b4;

        &::hover {
          transition: 0.1s ease-in;
          -webkit-transition: 0.1s ease-in;
          -moz-transition: 0.1s ease-in;
          -o-transition: 0.1s ease-in;
          -ms-transition: 0.1s ease-in;
        }
      }

      span {
        margin-right: 10px;
        color: #666;
      }
    }
  }
}
</style>

处理页面在请求后端时的加载状态组件和结束加载状态后的组件。

src/components下新增文件Loading.vue,代码如下:

<template>
  <div class="loading">
    <img
        src="../assets/loading.svg"
        alt="拼命加载中..."
    >
  </div>
</template>
<script lang="ts">
import {defineComponent} from "vue";

export default defineComponent({
  name: "CustomLoading",
});
</script>
<style scoped>
.loading {
  text-align: center;
  padding: 30px;
}
</style>

src/components下新增文件EndLoading.vue,代码如下:

<template>
  <div class="load-end"> --------------------------- 我也是有底线的啦 ---------------------------</div>
</template>
<script lang="ts">
import {defineComponent} from "vue";

export default defineComponent({
  name: "EndLoading",
});
</script>
<style scoped>
.load-end {
  text-align: center;
  padding: 30px;
  color: #969696;
  font-size: 14px;
}
</style>

3.1.4 Utils层

在文章列表中,我们为了更好的体验,对图片展示提供了限流处理和无极滚动加载。工具方法有:getDocumentHeightgetQueryStringByNamegetScrollTopgetWindowHeightthrottle,在src/utils/index.ts增加代码:

// fn是我们需要包装的事件回调, delay是时间间隔的阈值
export function throttle(fn: Function, delay: number) {
    let last = 0,
        timer: any = null;
    return function (this: any) {
        let context = (this as any);
        let args = arguments;
        let now = +new Date();

        if (now - last < delay) {
            clearTimeout(timer);
            timer = setTimeout(function () {
                last = now;
                fn.apply(context, args);
            }, delay);
        } else {
            last = now;
            fn.apply(context, args);
        }
    };
}

//根据 QueryString 参数名称获取值
export function getQueryStringByName(name: string) {
    let result = window.location.search.match(
        new RegExp("[?&]" + name + "=([^&]+)", "i")
    );
    if (result == null || result.length < 1) {
        return "";
    }
    return result[1];
}

//获取页面顶部被卷起来的高度
export function getScrollTop() {
    return Math.max(
        //chrome
        document.body.scrollTop,
        //firefox/IE
        document.documentElement.scrollTop
    );
}

//获取页面文档的总高度
export function getDocumentHeight() {
    //现代浏览器(IE9+和其他浏览器)和IE8的document.body.scrollHeight和document.documentElement.scrollHeight都可以
    return Math.max(
        document.body.scrollHeight,
        document.documentElement.scrollHeight
    );
}

//页面浏览器视口的高度
export function getWindowHeight() {
    return document.compatMode === "CSS1Compat"
        ? document.documentElement.clientHeight
        : document.body.clientHeight;
}

3.1.5 View

修改src/views/client/Home.vue文件,编写如下代码:

<template>
  <div class="left clearfix">
    <h3 v-if="state.params.tags" class="left-title">
      {{ state.tag_name }} 相关的文章:
    </h3>
    <ArticleList :article-list="state.articlesList" />
    <Loading v-if="state.isLoading"></Loading>
    <EndLoading v-if="state.isLoadEnd"></EndLoading>
  </div>
</template>

<script lang="ts">
import { defineComponent, nextTick, onMounted, reactive } from "vue";
import {
  getDocumentHeight,
  getQueryStringByName,
  getScrollTop,
  getWindowHeight,
  throttle,
} from "../../utils";
import EndLoading from "../../components/EndLoading.vue";
import Loading from "../../components/Loading.vue";
import { Article, ArticleArray, ArticleParams } from "../../types";
import { getArticleList } from "../../api/service";
import ArticleList from "../../components/ArticleList.vue";

const viewHeight = window.innerHeight || document.documentElement.clientHeight;
const lazyload = throttle(() => {
  const imgs = document.querySelectorAll("#list .item img");
  let num = 0;
  for (let i = num; i < imgs.length; i++) {
    let distance = viewHeight - imgs[i].getBoundingClientRect().top;
    let imgItem: any = imgs[i];
    if (distance >= 100) {
      let hasLaySrc = imgItem.getAttribute("data-has-lazy-src");
      if (hasLaySrc === "false") {
        imgItem.src = imgItem.getAttribute("data-src");
        imgItem.setAttribute("data-has-lazy-src", "true");
      }
      num = i + 1;
    }
  }
}, 1000);

export default defineComponent({
  name: "Home",
  components: {
    ArticleList,
    EndLoading,
    Loading,
  },
  watch: {
    "$store.state.articleParams": {
      handler(val: any, oldVal: any) {
        this.state.params.tags = val.tags;
        this.state.params.catalog = val.catalog;
        this.state.articlesList = [];
        this.state.params.page = 1;
        this.handleSearch();
      },
    },
  },
  setup() {
    const state = reactive({
      isLoadEnd: false,
      isLoading: false,
      articlesList: [] as Array<Article>,
      total: 0,
      tag_name: decodeURI(getQueryStringByName("tag_name")),
      params: {
        title: undefined,
        tags: undefined,
        catalog: undefined,
        page: 1,
        page_size: 10,
      } as ArticleParams,
    });

    const handleSearch = async (): Promise<void> => {
      state.isLoading = true;

      try {
        const data: ArticleArray = await getArticleList(state.params);
        state.isLoading = false;
        state.articlesList = [...state.articlesList, ...data.results];
        state.total = data.count;
        state.params.page++;
        await nextTick(() => {
          lazyload();
        });
        if (
          data.results.length === 0 ||
          state.total === state.articlesList.length
        ) {
          state.isLoadEnd = true;
          document.removeEventListener("scroll", () => {});
          window.onscroll = null;
        }
      } catch (e) {
        console.error(e);
        state.isLoading = false;
      }
    };

    onMounted(() => {
      window.onscroll = () => {
        if (getScrollTop() + getWindowHeight() > getDocumentHeight() - 100) {
          if (state.isLoadEnd === false && state.isLoading === false) {
            console.info("222");
            handleSearch();
          }
        }
      };
      document.addEventListener("scroll", lazyload);
      handleSearch();
    });

    return {
      state,
      handleSearch,
    };
  },
});
</script>


<style lang="less">
a {
  text-decoration: none;
}
</style>

3.1.5 Router

由于主页的路由已经在src/route/index.ts 文件中配置,这里增加Articles的路由。

{
        path: "/articles",
        name: "Articles",
        component: () =>
            import("../views/admin/Home.vue")
},

3.1.6 Less

src下新增文件夹less,新增index.less 代码如下:

body {
  padding: 10px;
  margin: 0;
}

a {
  text-decoration: none;
}

.clearfix:before,
.clearfix:after {
  display: table;
  content: '';
}

.layout {
  display: flex;
}

.right {
  width: 350px;
}

.left {

  flex: 1;
  padding-right: 20px !important;
}

.clearfix:after {
  clear: both;
}

.fl {
  float: left;
}

.fr {
  float: right;
}


h1,
h2,
h3,
h4,
h5,
h6 {
  margin-top: 1em;
}

strong {
  font-weight: bold;
}

p > code:not([class]) {
  padding: 2px 4px;
  font-size: 90%;
  color: #c7254e;
  background-color: #f9f2f4;
  border-radius: 4px;
}

img {
  max-width: 100%;
}

.container {
  width: 1200px;
  margin: 0 auto;
}

.article-detail {
  img {
    /* 图片居中 */
    display: flex;
    max-width: 100%;
    margin: 0 auto;
  }

  table {
    text-align: center;
    border: 1px solid #eee;
    margin-bottom: 1.5em;
  }

  th,
  td {
    // text-align: center;
    padding: 0.5em;
  }

  tr:nth-child(2n) {
    background: #f7f7f7;
  }
}

.article-detail {
  font-size: 16px;
  line-height: 30px;
}

.anchor-fix {
  position: relative !important;
  top: -80px !important;
  display: block !important;
  height: 0 !important;
  overflow: hidden !important;
}

.article-detail .desc ul,
.article-detail .desc ol {
  color: #333333;
  margin: 1.5em 0 0 25px;
}

.article-detail .desc h1,
.article-detail .desc h2 {
  border-bottom: 1px solid #eee;
  padding-bottom: 10px;
}

.article-detail .desc a {
  color: #009a61;
}

.article-detail blockquote {
  margin: 0 0 1em;
  background-color: rgb(220, 230, 240);
  padding: 1em 0 0.5em 0.5em;
  border-left: 6px solid rgb(181, 204, 226);
}

src/App.vuestyle部分,增加导入index.less的代码

@import url("./less/index.less");

3.2 文章详情

文章详情通过路径中的query参数传递文章id的方式区别不同的文章,这样的好处是方便文章可以通过url实现分享,比如想发表在公众号中,原文链接就可以直接用该URL

3.2.1 Type

文章详情中涉及到文章, 分类,标签,点赞、评论,结合之前已经定义的内容,在src/types/index.ts文件中增加代码如下:

export interface Like {
    article: number,
    user: number,
}

3.2.2 API

src/api/service.ts编写如下代码:

export function postLikeArticle(data: Like) {
    return request({
        url: '/like/',
        method: 'post',
        data,
    })
}

export function getArticleComments(articleId: number) {
    return request({
        url: '/comment/',
        method: 'get',
        params: {
            article: articleId,
        },
    }) as unknown as ResponseData
}

export function addComment(data: CommentPara) {
    return request({
        url: '/comment/',
        method: 'post',
        data
    })
}

3.2.3 Component

这里需要用到评论列表,评论组件。

src/components下新增文件Comment.vue,负责新增评论,代码如下:

<template>
  <div v-if="forArticle" class="comment">
    <el-input
      v-model="state.content"
      placeholder="文明社会,理性评论"
      type="textarea"
    />
    <el-button
      :loading="state.btnLoading"
      style="margin-top: 15px"
      type="primary"
      @click="handleOk"
    >
      发 送
    </el-button>
  </div>

  <el-dialog
    v-else
    v-model="state.showDialog"
    title="评论"
    width="60%"
    @close="cancel"
  >
    <el-form>
      <el-form-item>
        <el-input
          v-model="state.content"
          autocomplete="off"
          placeholder="文明社会,理性评论"
          type="textarea"
        />
      </el-form-item>
    </el-form>
    <template v-slot:footer>
      <div class="dialog-footer">
        <el-button type="default" @click="cancel">取消</el-button>
        <el-button type="primary" @click="handleOk">确 定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script lang="ts">
import { ElMessage } from "element-plus";
import { defineComponent, reactive, watch } from "vue";
import { addComment } from "../api/service";
import { useStore } from "vuex";
import { StateKey } from "../store";
import { CommentPara } from "../types";

export default defineComponent({
  name: "Comment",
  props: {
    forArticle: {
      type: Boolean,
      require: true,
    },
    showDialog: {
      type: Boolean,
      default: false,
    },
    article_id: {
      type: Number,
      require: true,
    },
    reply: {
      type: Number,
      default: undefined,
    },
  },
  emits: ["ok", "cancel"],
  setup(props, context) {
    const state = reactive({
      showDialog: props.showDialog,
      btnLoading: false,
      content: "",
      cacheTime: 0, // 缓存时间
      times: 0, // 留言次数
    });
    const store = useStore(StateKey);
    const cancel = (): boolean => {
      context.emit("cancel", false);
      return false;
    };

    const handleOk = async (): Promise<void> => {
      if (!props.article_id) {
        ElMessage({
          message: "该文章不存在!",
          type: "error",
        });
        return;
      }

      if (state.times > 2) {
        ElMessage({
          message: "您今天评论的次数已经用完,明天再来评论吧!",
          type: "warning",
        });
        return;
      }

      let now = new Date();
      let nowTime = now.getTime();
      if (nowTime - state.cacheTime < 4000) {
        ElMessage({
          message: "您评论太过频繁,1 分钟后再来评论吧!",
          type: "warning",
        });
        return;
      }

      if (!state.content) {
        ElMessage({
          message: "评论内容不能为空",
          type: "error",
        });
        return;
      }

      let user_id: number;
      if (store.state.user.id > 0) {
        user_id = store.state.user.id;
      } else {
        ElMessage({
          message: "登录才能评论,请先登录!",
          type: "warning",
        });
        return;
      }
      state.btnLoading = true;
      try {
        await addComment({
          article: props.article_id,
          user: user_id,
          reply: props.reply,
          content: state.content,
        } as CommentPara);
        state.btnLoading = false;
        state.times++;

        state.cacheTime = nowTime;
        state.content = "";
        context.emit("ok", false);
        ElMessage({
          message: "评论成功",
          type: "success",
        });
      } catch (e) {
        ElMessage({
          message: "评论失败,请重试哦",
          type: "success",
        });
        state.btnLoading = false;
      }
    };

    watch(props, (val, oldVal) => {
      state.showDialog = val.showDialog;
    });

    return {
      state,
      cancel,
      handleOk,
    };
  },
});
</script>
<style scoped>
.dialog-footer {
  text-align: right;
}
</style>

src/components下新增文件CommentList.vue,负责展示文章的评论列表,代码如下:

<template>
  <div class="comment-list">
    <div class="top-title">
      <span>{{ numbers }} 条评论</span>
    </div>
    <div v-for="(item, i) in state.comments" :key="item.id" class="item">
      <div class="item-header">
        <div class="author">
          <div class="avatar">
            <img
              v-if="!item.user_info.avatar"
              alt="默认图片"
              src="../assets/user.png"
            />
            <img v-else :src="item.user_info.avatar" alt="" />
          </div>
        </div>
        <div class="info">
          <div class="name">
            {{ item.user_info.name
            }}{{ item.user_info.role === "Admin" ? "(作者)" : "" }}
          </div>
          <div class="time">{{ formatTime(item.created_at) }}</div>
        </div>
      </div>
      <div class="comment-detail">{{ item.content }}</div>
      <div class="item-comment">
        <div
          class="message"
          @click="showCommentModal(item.id, item.user_info.id)"
        >
          <el-button size="small">回复</el-button>
        </div>
      </div>
      <div v-for="e in item.comment_replies" :key="e._id" class="item-other">
        <div class="item-header">
          <div class="author">
            <div class="avatar">
              <img
                v-if="!e.user_info.avatar"
                alt="默认图片"
                src="../assets/user.png"
              />
              <img v-else :src="e.user_info.avatar" alt="" />
            </div>
          </div>
          <div class="info">
            <div class="name">
              {{ e.user_info.name }}
              {{ e.user_info.role === "Admin" ? "(作者)" : "" }}
            </div>
            <div class="time">
              {{ formatTime(e.created_at) }}
            </div>
          </div>
        </div>
        <div class="comment-detail">
          {{ e.content }}
        </div>
      </div>
    </div>
    <Comment
      :article_id="article_id"
      :forArticle="false"
      :reply="state.comment_id"
      :show-dialog="state.visible"
      @cancel="handleCancel"
      @ok="handleOk"
    />
  </div>
</template>

<script lang="ts">
import { ElMessage } from "element-plus";
import {
  defineAsyncComponent,
  defineComponent,
  onMounted,
  reactive,
} from "vue";
import { timestampToTime } from "../utils";
import { CommentInfo } from "../types";
import { getArticleComments } from "../api/service";

export default defineComponent({
  name: "CommentList",
  components: {
    Comment: defineAsyncComponent(() => import("./Comment.vue")),
  },
  props: {
    numbers: {
      type: Number,
      default: 0,
    },
    article_id: {
      type: Number,
      default: undefined,
    },
  },

  setup(props, context) {
    const state = reactive({
      visible: false,
      comment_id: 0,
      comments: [] as Array<CommentInfo>,
      reply: 0,
    });

    const formatTime = (value: string | Date): string => {
      return timestampToTime(value, true);
    };

    const handleCancel = (): void => {
      state.visible = false;
    };

    const getCommentList = async () => {
      try {
        const response = await getArticleComments(props.article_id);
        state.comments = response.results as unknown as Array<CommentInfo>;
      } catch (e) {
        console.error(e);
      }
    };

    const handleOk = async (): Promise<void> => {
      state.visible = false;
      await getCommentList();
    };

    // 添加评论
    const showCommentModal = (
      commentId: number,
      user: number,
      secondUser?: number
    ): boolean | void => {
      if (!window.sessionStorage.userInfo) {
        ElMessage({
          message: "登录才能点赞,请先登录!",
          type: "warning",
        });
        return false;
      }
      // 添加三级评论
      if (secondUser) {
        state.comment_id = commentId;
      } else {
        // 添加二级评论
        state.comment_id = commentId;
      }
      state.visible = true;
    };
    onMounted(() => {
      getCommentList();
    });

    return {
      state,
      showCommentModal,
      handleOk,
      handleCancel,
      formatTime,
    };
  },
});
</script>
<style lang="less" scoped>
.comment-list {
  text-align: center;
}

.comment-list {
  position: relative;
  text-align: left;
  padding-top: 30px;
  margin-top: 30px;
  border-top: 1px solid #eee;

  .avatar {
    position: absolute;
    left: 0px;
  }

  .el-icon-circle-plus {
    font-size: 40px;
  }
}

.clearfix {
  clear: both;
}

.comment-list {
  margin-top: 30px;

  .top-title {
    padding-bottom: 20px;
    font-size: 17px;
    font-weight: 700;
    border-bottom: 1px solid #f0f0f0;
  }

  .item {
    padding: 20px 0 30px;
    border-bottom: 1px solid #f0f0f0;

    .item-header {
      position: relative;
      padding-left: 45px;
      padding-bottom: 10px;

      .author {
        position: absolute;
        left: 0;
        display: inline-block;

        .avatar {
          display: inline-block;
          margin-right: 5px;
          width: 40px;
          height: 40px;
          vertical-align: middle;

          img {
            width: 100%;
            height: 100%;
            border-radius: 50%;
          }
        }
      }

      .info {
        display: inline-block;

        .name {
          font-size: 15px;
          color: #333;
        }

        .time {
          font-size: 12px;
          color: #969696;
        }
      }
    }

    .comment-detail {
      min-height: 40px;
    }

    .item-comment {
      .like {
        margin-right: 20px;
      }
    }
  }
}

.item-other {
  margin: 20px;
  padding: 10px;
  border-left: 2px solid #f0f0f0;

  .item-header {
    position: relative;
    padding-left: 45px;
    padding-bottom: 10px;

    .author {
      position: absolute;
      left: 0;
      display: inline-block;

      .avatar {
        display: inline-block;
        margin-right: 5px;
        width: 38px;
        height: 38px;
        vertical-align: middle;

        img {
          width: 100%;
          height: 100%;
          border-radius: 50%;
        }
      }
    }

    .info {
      display: inline-block;

      .name {
        font-size: 15px;
        color: #333;
      }

      .time {
        font-size: 12px;
        color: #969696;
      }
    }
  }

  .comment-detail {
    min-height: 40px;
    border-bottom: 1px dashed #f0f0f0;
  }

  .message {
    padding: 10px;
  }
}
</style>

3.2.4 Util层

由于我们编写的文章正文是用markdown的方式记录的,而在博客网站上需要展示的是HTML,因此我们需要在展示之前将markdown转换成html,同时能展示文章的章节目录,所以先安装依赖

yarn add marked@2.0.3

src/utils下新增文件markdown.ts,编写代码如下:

import highlight from 'highlight.js'
// @ts-ignore
import marked from 'marked'

const tocObj = {
    add: function (text: any, level: any) {
        let anchor = `#toc${level}${++this.index}`;
        this.toc.push({anchor: anchor, level: level, text: text});
        return anchor;
    },

    toHTML: function () {
        let levelStack: any = [];
        let result = "";
        const addStartUL = () => {
            result += '<ul class="anchor-ul" id="anchor-fix">';
        };
        const addEndUL = () => {
            result += "</ul>\n";
        };
        const addLI = (anchor: any, text: any) => {
            result +=
                '<li><a class="toc-link" href="#' + anchor + '">' + text + "<a></li>\n";
        };

        this.toc.forEach(function (item: any) {
            let levelIndex = levelStack.indexOf(item.level);
            // 没有找到相应level的ul标签,则将li放入新增的ul中
            if (levelIndex === -1) {
                levelStack.unshift(item.level);
                addStartUL();
                addLI(item.anchor, item.text);
            } // 找到了相应level的ul标签,并且在栈顶的位置则直接将li放在此ul下
            else if (levelIndex === 0) {
                addLI(item.anchor, item.text);
            } // 找到了相应level的ul标签,但是不在栈顶位置,需要将之前的所有level出栈并且打上闭合标签,最后新增li
            else {
                while (levelIndex--) {
                    levelStack.shift();
                    addEndUL();
                }
                addLI(item.anchor, item.text);
            }
        });
        // 如果栈中还有level,全部出栈打上闭合标签
        while (levelStack.length) {
            levelStack.shift();
            addEndUL();
        }
        // 清理先前数据供下次使用
        this.toc = [];
        this.index = 0;
        return result;
    },
    toc: [] as any,
    index: 0
};

class MarkUtils {
    private readonly rendererMD: any;

    constructor() {
        this.rendererMD = new marked.Renderer() as any;
        this.rendererMD.heading = function (text: any, level: any, raw: any) {
            let anchor = tocObj.add(text, level);
            return `<a id=${anchor} class="anchor-fix"></a><h${level}>${text}</h${level}>\n`;
        };
        this.rendererMD.table = function (header: any, body: any) {
            return '<table class="table" border="0" cellspacing="0" cellpadding="0">' + header + body + '</table>'
        }
        highlight.configure({useBR: true});
        marked.setOptions({
            renderer: this.rendererMD,
            headerIds: false,
            gfm: true,
            // tables: true,
            breaks: false,
            pedantic: false,
            sanitize: false,
            smartLists: true,
            smartypants: false,
            highlight: function (code: any) {
                return highlight.highlightAuto(code).value;
            }
        });
    }

    async marked(data: any) {
        if (data) {
            let content = await marked(data);
            let toc = tocObj.toHTML();
            return {content: content, toc: toc};
        } else {
            return null;
        }
    }
}

const markdown: any = new MarkUtils();

export default markdown;

3.2.5 View

src/views/client下新增文件ArticleDetail.vue文件,编写如下代码:

<template>
  <div style="width: 100%">
    <div class="article clearfix">
      <div v-show="!state.isLoading" :style="{'width': '75%'}" class="article-left fl">
        <div class="header">
          <h1 class="title">{{ state.detail.title }}</h1>
          <div class="author">
            <div class="avatar">
              <img alt="落霞孤鹜" class="auth-logo" src="../../assets/myAvatar.jpg">
            </div>
            <div class="info">
              <span class="name">
                <span>{{ state.detail.author }}</span>
              </span>
              <div data-author-follow-button="" props-data-classes="user-follow-button-header"/>
              <div class="meta">
                <span class="publish-time">
                  {{ state.detail.created_at ? formatTime(state.detail.created_at) : '' }}
                </span>
                <span class="wordage">字数 {{ state.detail.words }}</span>
                <span class="views-count">阅读 {{ state.detail.views }}</span>
                <span class="comments-count">评论 {{ state.detail.comments }}</span>
                <span class="likes-count"> 喜欢 {{ state.detail.likes }}</span>
              </div>
            </div>
            <div class="tags" title="标签">
              <el-tag v-for="tag in state.detail.tags_info" :key="tag.id" class="tag" size="mini" type="success">
                {{ tag.name }}
              </el-tag>
            </div>
            <span class="clearfix"/>
          </div>
        </div>
        <div class="content">
          <div id="content" class="article-detail" v-html="state.detail.html"></div>
        </div>
        <div class="heart">
          <el-button :loading="state.isLoading" icon="heart" size="large" type="danger" @click="likeArticle">
            点赞
          </el-button>
        </div>
        <Comment :article_id="state.params" :for-article="true" :show-dialog="false"/>
        <CommentList :article_id="state.params" :numbers="state.detail.comments"/>
      </div>
      <div class="article-right fr anchor" style="width: 23%"
           v-html="state.detail.toc"></div>
      <Loading v-if="state.isLoading"/>
    </div>
  </div>
</template>

<script lang="ts">
import {defineComponent, onMounted, reactive} from "vue";
import {ElMessage} from "element-plus";
import {useRoute} from "vue-router";
import {timestampToTime} from "../../utils";
import markdown from "../../utils/markdown";
import Loading from "../../components/Loading.vue";
import CommentList from "../../components/CommentList.vue";
import {Article, Catalog, Like, Tag,} from "../../types";
import {getArticleDetail, postLikeArticle} from "../../api/service";
import {StateKey} from "../../store";
import {useStore} from "vuex";
import Comment from "../../components/Comment.vue";

declare let document: Document | any;

export default defineComponent({
  name: "ArticleDetail",
  components: {
    Comment,
    Loading,
    CommentList,
  },

  setup() {
    const store = useStore(StateKey)

    const state = reactive({
      btnLoading: false,
      isLoadEnd: false,
      isLoading: false,
      params: 0,
      content: "",
      detail: {
        id: 0,
        title: "",
        excerpt: "",
        cover: "",
        markdown: "",
        created_at: "",
        modified_at: "",
        tags_info: [] as Array<Tag>,
        catalog_info: {} as Catalog,
        views: 0,
        comments: 0,
        words: 100,
        likes: 0,
        author: '落霞孤鹜',
      } as Article,
      cacheTime: 0, // 缓存时间
      times: 0, // 评论次数
      likeTimes: 0, // 点赞次数
    });

    const formatTime = (value: string | Date): string => {
      return timestampToTime(value, true);
    };

    const handleSearch = async (): Promise<void> => {
      state.isLoading = true;
      try {
        const data: any = await getArticleDetail(state.params);
        state.isLoading = false;

        state.detail = data;
        const article = markdown.marked(data.markdown);
        article.then((res: any) => {
          state.detail.html = res.content;
          state.detail.toc = res.toc;
        });
        document.title = data.title;
        document.querySelector("#keywords").setAttribute("content", data.keyword);
        document.querySelector("#description")
            .setAttribute("content", data.excerpt);

      } catch (e) {
        state.isLoading = false;
      }

    };


    const likeArticle = async (): Promise<void> => {
      if (!state.detail.id) {
        ElMessage({
          message: "该文章不存在!",
          type: "warning",
        });
        return;
      }

      if (state.likeTimes > 0) {
        ElMessage({
          message: "您已经点过赞了!悠着点吧!",
          type: "warning",
        });
        return;
      }

      const user_id: number = store.state.user.id;
      if (user_id === 0) {
        ElMessage({
          message: "登录才能点赞,请先登录!",
          type: "warning",
        });
        return;
      }

      let params: Like = {
        article: state.detail.id,
        user: user_id,
      };
      try {
        await postLikeArticle(params);

        state.isLoading = false;

        state.likeTimes++;
        ++state.detail.likes;
        ElMessage({
          message: "操作成功",
          type: "success",
        });
      } catch (e) {
        state.isLoading = false;
      }
    };

    const route = useRoute()
    if (route.path === '/about') {
      state.params = 1
    } else {
      state.params = Number(route.query.id)
    }

    onMounted(() => {
      handleSearch();
    });

    return {
      state,
      formatTime,
      handleSearch,
      likeArticle,
    };
  },
  beforeUnmount(): void {
    document.title = "落霞孤鹜的博客网站";
    document
        .getElementById("keywords")
        .setAttribute("content", "落霞孤鹜 的博客网站");
    document
        .getElementById("description")
        .setAttribute(
            "content",
            "分享人工智能相关的产品和技术。"
        );
  },
});
</script>
<style lang="less" scoped>
.anchor {
  display: block;
  position: sticky;
  top: 213px;
  margin-top: 213px;
  border-left: 1px solid #eee;
  min-height: 48px;

  .anchor-ul {
    position: relative;
    top: 0;
    max-width: 250px;
    border: none;
    -moz-box-shadow: 0 0 0 #fff;
    -webkit-box-shadow: 0 0 0 #fff;
    box-shadow: 0 0 0 #fff;

    li.active {
      color: #009a61;
    }
  }

  a {
    color: #333;
  }
}

.article {
  width: 100%;

  .header {
    border-bottom: #eeeeee 1px solid;

    .title {
      margin: 0;
      text-align: center;
      font-size: 34px;
      font-weight: bold;
    }

    .author {
      position: relative;
      margin: 30px 0 40px;
      padding-left: 50px;

      .avatar {
        position: absolute;
        left: 0;
        top: 0;
        width: 48px;
        height: 48px;
        vertical-align: middle;
        display: inline-block;

        img {
          width: 100%;
          height: 100%;
          border-radius: 50%;
        }
      }

      .info {
        float: left;
        vertical-align: middle;
        // display: inline-block;
        margin-left: 8px;

        a {
          color: #333;
        }
      }

      .name {
        margin-right: 3px;
        font-size: 16px;
        vertical-align: middle;
      }

      .meta {
        margin-top: 5px;
        font-size: 12px;
        color: #969696;

        span {
          padding-right: 5px;
        }
      }

      .tags {
        float: right;
        padding-top: 15px;
        // padding-right: 20px;
        .tag {
          // padding: 0 10px;
          margin-left: 5px;
          border-right: 2px solid #eee;
        }
      }
    }
  }

  .content {
    min-height: 300px;
  }
}

.heart {
  height: 60px;
  text-align: center;
  margin: 50px;
}

.loader {
  color: rgb(226, 44, 44);
  text-align: center;
  padding: 50px;
  font-size: 16px;
}

.clearfix {
  clear: both;
}

.anchor-fix1 {
  display: block;
  height: 60px; /*same height as header*/
  margin-top: -60px; /*same height as header*/
  visibility: hidden;
}

</style>

3.2.6 Router

定义route来完成路由跳转。在src/route/index.ts 文件中新增代码:

{
        path: "/article/",
        name: "ArticleDetail",
        component: () =>
            import("../views/client/ArticleDetail.vue")
},

3.3 分类

3.3.1 Store

为了实现文章列表组件的复用,我们在Store保存当前的文章的检索条件,调整后的src/store/index.ts

import {InjectionKey} from 'vue'
import {createStore, Store} from 'vuex'
import { Nav, User, ArticleParams} from "../types";

export interface State {
    user: User,
    navIndex: string,
    navs: Array<Nav>,
}

export const StateKey: InjectionKey<Store<State>> = Symbol();

export const SET_USER = 'setUser';
export const CLEAR_USER = 'clearUser'
export const SET_NAV_INDEX = 'setNavIndex'
export const SET_ARTICLE_PARAMS = 'setArticleParams'


export const initDefaultUserInfo = (): User => {
    let user: User = {
        id: 0,
        username: "",
        avatar: "",
        email: '',
        nickname: '',
        is_superuser: false,
    }
    if (window.sessionStorage.userInfo) {
        user = JSON.parse(window.sessionStorage.userInfo);
    }
    return user
}

export const initDefaultArticleParams = (): ArticleParams => {
    let params: ArticleParams = {
        title: undefined,
        status: 'Published',
        tags: undefined,
        catalog: undefined,
        page: 1,
        page_size: 10,
    }
    if (window.sessionStorage.articleParams) {
        params = JSON.parse(window.sessionStorage.articleParams);
    }
    return params
}

export const store = createStore<State>({
    state() {
        return {
            user: initDefaultUserInfo(),
            articleParams: initDefaultArticleParams(),
            navIndex: '1',
            navs: [
                {
                  index: "1",
                  path: "/",
                  name: "主页",
                },
                {
                  index: "2",
                  path: "/catalog",
                  name: "分类",
                },
                {
                  index: "3",
                  path: "/archive",
                  name: "归档",
                },
                {
                  index: "4",
                  path: "/about",
                  name: "关于",
                },
              ],
        }
    },
    mutations: {
        setUser(state: object | any, userInfo: object | any) {
            for (const prop in userInfo) {
                state[prop] = userInfo[prop];
            }
        },
        clearUser(state: object | any) {
            state.user = initDefaultUserInfo();
        },

        setNavIndex(state: object | any, navIndex: string) {
            state.navIndex = navIndex
        },

        setArticleParams(state: object | any, params: object) {
            state.articleParams = {...state.articleParams, ...params}
        }
    },
})

3.3.2 View

通过表格查看评论,在src/views/client下新增文件Catalog.vue文件,编写如下代码:

<template>
  <div class="catalog">
    <div :style="{ 'min-height': height + 'px' }" class="catalog-tree">
      <el-tree
        :current-node-key="state.currentNodeKey"
        :data="state.catalogs"
        :props="defaultProps"
        node-key="id"
        @node-click="handleNodeClick"
      ></el-tree>
    </div>
    <div class="article-list">
      <Home />
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, reactive } from "vue";
import { Article, ArticleParams, Catalog } from "../../types";
import { getCatalogTree } from "../../api/service";
import Home from "./Home.vue";
import { SET_ARTICLE_PARAMS, StateKey } from "../../store";
import { useStore } from "vuex";

export default defineComponent({
  name: "Catalog",
  components: { Home },

  setup() {
    const state = reactive({
      catalogs: [] as Array<Catalog>,
      articleParams: { catalog: 1 } as ArticleParams,
      articleList: [] as Array<Article>,
      currentNodeKey: 1,
    });

    const getCatalogs = async () => {
      state.catalogs = await getCatalogTree();
    };

    const defaultProps = {
      children: "children",
      label: "name",
    };

    const store = useStore(StateKey);

    onMounted(() => {
      getCatalogs();
      state.currentNodeKey = store.state.articleParams.catalog || 1;
    });

    let height = window.innerHeight || document.documentElement.clientHeight;
    height = height - 200;
    const handleNodeClick = (catalog: Catalog) => {
      store.commit(SET_ARTICLE_PARAMS, { catalog: catalog.id });
    };
    return {
      state,
      defaultProps,
      height,
      handleNodeClick,
    };
  },
});
</script>

<style lang="less" scoped>
.catalog {
  display: flex;
}

.catalog-tree {
  width: 200px;
  border-right: 1px solid #eeeeee;
  margin-right: 24px;
  padding-top: 24px;
  margin-top: -12px;
  color: #2c3e50;
}

.article-list {
  width: 70%;
}
</style>

3.3.5 Router

定义route来完成路由跳转。在src/route/index.ts 文件中新增代码:

 {
        path: '/catalog',
        name: 'Catalog',
        component: () =>
            import("../views/client/Catalog.vue")
    },

3.4 归档

3.4.1 Type

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

export interface PageInfo {
    page: number,
    page_size: number
}

export interface ArticleArchiveList {
    year: number,
    list: Array<Article> | any
}

3.4.2 API

列表查询,在src/api/service.ts编写如下代码:

export function getArchiveList(params: PageInfo) {
    return request({
        url: '/archive/',
        method: 'get',
        params
    })
}

3.4.3 Store

src/store/index.ts 中增加如下代码:

export const SET_NAV_INDEX_BY_ROUTE = 'setNavIndexByRoute'

src/store/index.ts 中的store中增加如下代码:

actions: {
        setNavIndexByRoute({commit, state}, route: string) {
            const index = state.navs.findIndex(r => r.path === route)
            if (state.navIndex === state.navs[index].index)
                return
            if (index > -1) {
                commit(SET_NAV_INDEX, state.navs[index].index)
            }
        }
    }

3.4.4 View

src/views/client下新增文件Archive.vue文件,编写如下代码:

<template>
  <div class="archive left">
    <el-timeline>
      <el-timeline-item v-for="(l, i) in state.articlesList" :key="l.year" hide-timestamp placement="top">
        <h3 class="year">{{ l.year }}</h3>
        <el-timeline-item
            v-for="(item, index) in l.list"
            :key="item.id"
            hide-timestamp
            placement="top"
        >
          <router-link :to="`/article/?id=${item.id}`" target="_blank">
            <h3 class="title">{{ item.title }}</h3>
          </router-link>
          <p>{{ formatTime(item.created_at) }}</p>
        </el-timeline-item>
      </el-timeline-item>
    </el-timeline>
  </div>
</template>
<script lang="ts">
import {defineComponent, onMounted, reactive} from "vue";
import {timestampToTime} from "../../utils";
import {ArticleArchiveList, PageInfo} from "../../types";
import {getArchiveList} from "../../api/service";
import {useStore} from "vuex";
import {SET_NAV_INDEX_BY_ROUTE, StateKey} from "../../store";


export default defineComponent({
  name: "Archive",
  setup() {
    const state = reactive({
      isLoadEnd: false,
      isLoading: false,
      articlesList: [] as Array<ArticleArchiveList>,
      total: 0,
      params: {
        page: 1,
        page_size: 10,
      } as PageInfo
    });

    const formatTime = (value: string | Date): string => {
      return timestampToTime(value, true);
    }

    const handleSearch = async (): Promise<void> => {
      state.isLoading = true;
      const params: PageInfo = state.params
      try {
        const data: any = await getArchiveList(params)
        state.isLoading = false;
        state.articlesList = [...state.articlesList, ...data.results];
        state.total = data.count;
        state.params.page++;
        if (state.total === state.articlesList.length) {
          state.isLoadEnd = true;
        }
      } catch (e) {
        state.isLoading = false;
      }

    }

    onMounted(() => {
      const store = useStore(StateKey)
      store.dispatch(SET_NAV_INDEX_BY_ROUTE, '/archive')
      handleSearch();
    })

    return {
      state,
      formatTime,
      handleSearch
    };
  },
})
;
</script>

<style lang="less" scoped>
.archive {
  padding: 40px 0;

  .year {
    font-size: 30px;
    font-weight: bold;
    color: #000;
    margin-top: 0;
  }

  a {
    text-decoration: none;
  }

  .title {
    color: #333;

    &:hover {
      color: #1890ff;
    }
  }
}
</style>

3.4.5 Router

定义route来完成路由跳转。在src/route/index.ts 文件中新增代码:

{
        path: "/archive/",
        name: "Archive",
        component: () =>
            import("../views/client/Archive.vue")
    },

3.5 关于

3.5.1 改造index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <link href="/favicon.ico" rel="icon"/>
    <meta id="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
          name="viewport"/>
    <title>微谈小智</title>

    <meta id="referrer" content="always" name="referrer"/>
    <meta content="7XGPmF2RtW" name="baidu-site-verification"/>
    <meta id="keywords" content="落霞孤鹜 的博客网站,技术性产品经理" name="keywords"/>
    <meta id="description" content="落霞孤鹜 的博客网站。落霞孤鹜,目前是一名AI产品经理,略懂技术 公众号作者,致力于人工智能相关的产品和技术分享。" name="description"/>
</head>
<body>
<div id="app"></div>
<script src="/src/main.ts" type="module"></script>
</body>
</html>

3.5.2 View

通过表格查看评论,在src/views/client/ArticleDetail.vue文件的 180~185 行增加了对about的处理文件,如下代码:

const route = useRoute()
    if (route.path === '/about') {
      state.params = 1
    } else {
      state.params = Number(route.query.id)
    }

3.5.2 Router

定义route来完成路由跳转。在src/route/index.ts 文件中新增代码:

{
        path: '/about',
        name: 'About',
        component: () =>
            import("../views/client/ArticleDetail.vue")
    },

至此,博客针对用户的页面全部开发完成。

四、界面效果

4.1 首页

image-20210823235213587

4.2 分类

image-20210823235241587

4.3 归档

image-20210823235400251

4.4 文章详情和关于

image-20210823235337357

五、项目代码

项目的代码按照章节的代码进行的提交,能从提交记录中看到每一个模块是如何添加进去的。

个人博客地址:微谈小智 (longair.cn)

前端代码地址:https://gitee.com/Zhou_Jimmy/blog-frontend.git

后端代码地址:https://gitee.com/Zhou_Jimmy/blog-backend.git

相关文章

  • Vue3+TypeScript+Django Rest Fram

    博客网站最重要的是有一个给用户浏览文章的页面,也就是博客网站的前台,用户通过这个页面可以查找文章,浏览文章详情,评...

  • Vue3+TypeScript+Django Rest Fram

    博客网站已经部署完成,在过程中我们解决了很多问题,曾子曰:吾日三省,这一篇就是总结我们遇到的问题,俗称:踩坑 大家...

  • Vue3+TypeScript+Django Rest Fram

    本文适合对有 Python 语言有一定基础的人群,希望利用 Python 做更多有意思的事情,比如搭建个人博客,记...

  • Vue3+TypeScript+Django Rest Fram

    本文适合对有 Python 语言有一定基础的人群,希望利用 Python 做更多有意思的事情,比如搭建个人博客,记...

  • Vue3+TypeScript+Django Rest Fram

    用户登录功能是一个信息系统必不可少的一部分,作为博客网站,同样需要管理员登录管理后台,游客注册后登录评论等 大家好...

  • Vue3+TypeScript+Django Rest Fram

    一个完整的网站都是有前台和管理后台组成的,前台用来给真正的用户浏览和使用,后台用来给管理员管理网站内容,配置各种功...

  • Vue3+TypeScript+Django Rest Fram

    一个完整的网站都是有前台和管理后台组成的,前台用来给真正的用户浏览和使用,后台用来给管理员管理网站内容,配置各种功...

  • Vue3+TypeScript+Django Rest Fram

    一个完整的网站都是有前台和管理后台组成的,前台用来给真正的用户浏览和使用,后台用来给管理员管理网站内容,配置各种功...

  • Vue3+TypeScript+Django Rest Fram

    博客网站开发完成后,最后一棒是将博客网站部署到公网服务器上,提供域名访问,能通过搜索引擎搜索到的博客网站才是真正的...

  • Rest-framework-API参考-GenericAPIV

    API Reference GenericAPIView This class extends REST fram...

网友评论

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

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