Vue3+TypeScript+Django Rest Framework build personal blog (4) : blog page

The most important thing of blog website is that there is a page for users to browse articles, which is the front desk of blog website. Users can search articles, browse article details, comment, like and so on through this page.

Hello everyone, I am Luo Xia Gu Fan. In the last article, we have the management background function of the blog. In this chapter, we start to build the front desk of the blog to realize the functions of viewing, browsing, commenting and liking articles on the blog website. I also explained how to implement it according to a complete function, from requirements analysis to code writing.

I. Demand analysis

As a complete blog site, the front desk is the core part of the content presentation, and most of the blog construction articles focus on this part. Based on actual needs, we sorted out the following requirements:

  1. Home page: mainly displays the articles of the whole blog website, generally presented in reverse order according to the release time, showing titles, abstracts, views, likes, comments, messages and other content, providing label screening
  2. Article details: mainly used to display the details of the article, covering all the details of the article, at the same time to provide the article chapter directory navigation, like, comment functions.
  3. Article classification: present the list of articles by category, so that users can quickly find articles they are interested in by type.
  4. Archive: Presents a list of posts from a blog site in reverse chronological order.
  5. About: General introduction to the blog of the blogger and blog site subject information.

The above functions can also be regarded as a simple set of personal blog site’s core functional framework.

2. Back-end interface

The back-end interface part was fully implemented in the management backend in the previous article and will not be covered here.

Three, front-end interface

Front end according to the demand, we from the home page, article details, article classification, archiving, about the five parts of the presentation. This part of the page is all in the SRC /views/client file.

The function of the home page, in fact, is a list of articles to display, and the classification page is to increase the list of articles to display a classification tree, so in the design of the page, the article display list as a component, so the classification display can be assembled by the list and classification of two components.

The About page can be analogous to an article details page that introduces the blog and the blogger, so the article details display can be used as a component to support the details page and about page.

3.1 the home page

3.1.1 Typelayer

While dealing with the admin background, the interfaceArticle, ArticleArray, and ArticleParams related to the article have been defined in the SRC /types/index.ts file.

3.1.2 APIlayer

The getArticleList method is already defined in the SRC/API /service.ts file when dealing with the admin background.

3.1.3 Component

According to the above analysis, we need to wrap the list of articles as a component, so add the file articlelist. vue under SRC/Components, and view the details of the article in a new page by clicking on the article:

<template> <ul id="list" class="articles-list"> <transition-group name="el-fade-in"> <li v-for="article in articleList" <a :href="href + article. Id "target="_blank"> <img :data-src="article. Cover" Alt ="article. 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. Views}}</p> </a> <div class="meta"> <span> View {{article. Views}</span> <span> comment {{article.comments {{article. Likes}}</span> <span> <router-link v-for="tag in article. Tags_info ":key="tag. :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; The 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; - its - the transition: 0.1 s ease - in; Moz - the transition: 0.1 s ease - in; - o - the transition: 0.1 s ease - in; - ms - the transition: 0.1 s ease - in; } } span { margin-right: 10px; color: #666; } } } } </style>Copy the code

Handles the loading state components of the page at the request back end and the components after the loading state.

Add Loading. Vue under SRC /components as follows:

<template> <div class="loading"> <img src=".. /assets/ load. SVG "Alt =" loading... > </div> </template> <script lang="ts"> import {defineComponent} from "vue"; export default defineComponent({ name: "CustomLoading", }); </script> <style scoped> .loading { text-align: center; padding: 30px; } </style>Copy the code

Add endload. vue under SRC /components:

< the template > < div class = "load - end" > -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- I'm also is to have the bottom line -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- < / 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>Copy the code

3.1.4 Utils layer

In the article list, for a better experience, we provide the image display to limit the flow and endless scrolling loading. GetDocumentHeight, getQueryStringByName, getScrollTop, getWindowHeight, Throttle, add code to SRC /utils/index.ts:

// fn is the event callback we need to wrap, delay is the threshold of the time interval
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(a);if (now - last < delay) {
            clearTimeout(timer);
            timer = setTimeout(function () {
                last = now;
                fn.apply(context, args);
            }, delay);
        } else{ last = now; fn.apply(context, args); }}; }// Get the value based on the QueryString parameter name
export function getQueryStringByName(name: string) {
    let result = window.location.search.match(
        new RegExp("[?] of&" + name + "= ([^ &] +)"."i"));if (result == null || result.length < 1) {
        return "";
    }
    return result[1];
}

// Gets the height at which the top of the page is rolled up
export function getScrollTop() {
    return Math.max(
        //chrome
        document.body.scrollTop,
        //firefox/IE
        document.documentElement.scrollTop
    );
}

// Get the total height of the page document
export function getDocumentHeight() {
    / / modern browsers (ie 9 + and other browsers) and Internet explorer of the document. The body. The scrollHeight and document documentElement. ScrollHeight will do
    return Math.max(
        document.body.scrollHeight,
        document.documentElement.scrollHeight
    );
}

// The height of the page browser viewport
export function getWindowHeight() {
    return document.compatMode === "CSS1Compat"
        ? document.documentElement.clientHeight
        : document.body.clientHeight;
}
Copy the code

3.1.5 Viewlayer

Modify the SRC/views/client/Home. Vue file, write the following code:

<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>Copy the code

3.1.5 Routerlayer

The route of Articles is added because the route of the home page has been configured in the SRC /route/index.ts file.

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

3.1.6 Less

Add folder “less” under SRC. Add “index.less” as follows:

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 {
    /* Image centered */
    display: flex;
    max-width: 100%;
    margin: 0 auto;
  }

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

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

  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.5 em 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.5 em 0.5 em;
  border-left: 6px solid rgb(181.204.226);
}
Copy the code

Add the code to import index.less in the SRC/app. vue style section

@import url("./less/index.less");
Copy the code

3.2 Article Details

Article details through the path of the query parameter to pass the article ID of the way to distinguish different articles, this advantage is convenient article can be shared through the URL, such as want to be published in the public number, the original link can directly use the URL

3.2.1 Typelayer

The details of the article are related to the article, classification, tag, likes and comments. Combined with the previously defined content, add the code in SRC /types/index.ts file as follows:

export interface Like {
    article: number.user: number,}Copy the code

3.2.2 APIlayer

Write the following code in 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
    })
}
Copy the code

3.2.3 Component

Here you need the comment list, the comment component.

Add file comment. vue under SRC /components to add comments as follows:

<template> <div v-if="forArticle" class="comment"> <el-input v-model="state.content" placeholder=" <el-button :loading=" state.btnloading "style="margin-top: 16px; 15px" type="primary" @click="handleOk" > </el-button> </div> <el-dialog v-else V-model =" state.showdialog "title=" comment"  width="60%" @close="cancel" > <el-form> <el-form-item> <el-input v-model="state.content" autocomplete="off" Placeholder =" Civilization, "Type ="textarea" /> </el-form-item> </el-form> <template V-slot :footer> <div class="dialog-footer"> <el-button </el-button> </el-button type="primary" @click="handleOk"> </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: ShowDialog, btnLoading: false, content: "", cacheTime: 0, // times: 0, // times of leaving messages}); const store = useStore(StateKey); const cancel = (): boolean => { context.emit("cancel", false); return false; }; const handleOk = async (): Promise<void> => { if (! Function.article_id) {ElMessage({message: "This article does not exist!" , type: "error", }); return; } if (state. Times > 2) {ElMessage({message: "you have run out of times to comment today, please comment tomorrow!" , type: "warning", }); return; } let now = new Date(); let nowTime = now.getTime(); If (nowtime-state.cachetime < 4000) {ElMessage({message: "You are commenting too often, come back in a minute!" , type: "warning", }); return; } if (! State. Content) {ElMessage({message: "comment content cannot be empty ", type: "error",}); return; } let user_id: number; if (store.state.user.id > 0) { user_id = store.state.user.id; } else {ElMessage({message: "Login to comment, please login first!" , 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: "comment successful ", type: "success",}); } catch (e) {ElMessage({message: "comment failed, please try again ", 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>Copy the code

Add a file called commentlist. vue under SRC /components to display a list of comments for articles as follows:

<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 =" default image "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"? "(author)" : "" }} </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"> Reply </el-button> </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 =" default image" SRC =".. /assets/user.png" /> <img v-else :src="e.user_info.avatar" alt="" /> </div> </div> <div class="info"> <div class="name"> {{e.u ser_info. Name}} {{e.u ser_info. Role = = = "Admin"? "(author)" : "" }} </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. The sessionStorage. The userInfo) {ElMessage ({message: "login to thumb up, please login first!" , type: "warning", }); return false; } if (secondUser) {state.comment_id = commentId; } else {// Add a secondary comment 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>Copy the code

3.2.4 Util layer

Since the body of the article we wrote is recorded by Markdown, while HTML is needed to be displayed on the blog website, we need to convert Markdown into HTML before displaying the article, and at the same time display the chapter catalog of the article, so we install the dependency first

Yarn add [email protected]Copy the code

Add file markdown.ts under SRC /utils and write the following code:

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

const tocObj = {
    add: function (text: any, level: any) {
        let anchor = `#toc${level}The ${+ +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);
            // If no ul label of the corresponding level is found, insert li into the newly added UL
            if (levelIndex === -1) {
                levelStack.unshift(item.level);
                addStartUL();
                addLI(item.anchor, item.text);
            } // Find the ul label of the corresponding level, and put li directly under this ul at the top of the stack
            else if (levelIndex === 0) {
                addLI(item.anchor, item.text);
            } // The ul label of the corresponding level is found, but it is not at the top of the stack
            else {
                while(levelIndex--) { levelStack.shift(); addEndUL(); } addLI(item.anchor, item.text); }});// If there is a level in the stack, put a close label on it
        while (levelStack.length) {
            levelStack.shift();
            addEndUL();
        }
        // Clean up the previous data for next use
        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) {
                returnhighlight.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;
Copy the code

3.2.5 Viewlayer

Add the articledetail. vue file under SRC /views/client and write the following code:

<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>
Copy the code

3.2.6 Routerlayer

Define a route to complete route hops. Add code to SRC /route/index.ts file:

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

3.3 classification

3.3.1 Storelayer

In order to reuse the article list component, we save the current article retrieval criteria in Store, adjusted 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(a);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: "Home page"}, {index: "2".path: "/catalog".name: "Classification"}, {index: "3".path: "/archive".name: "Archive"}, {index: "4".path: "/about".name: "About",},],}},mutations: {
        setUser(state: object | any, userInfo: object | any) {
            for (const prop inuserInfo) { 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} } }, })Copy the code

3.3.2 rainfall distribution on 10-12Viewlayer

View comments from the table, add file Catalog. Vue under SRC /views/client, write the following code:

<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>Copy the code

3.3.5 Routerlayer

Define a route to complete route hops. Add code to SRC /route/index.ts file:

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

3.4 archive

3.4.1 trackTypelayer

Add the following code to the SRC /types/index.ts file:

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

export interface ArticleArchiveList {
    year: number.list: Array<Article> | any
}
Copy the code

3.4.2 APIlayer

SRC/API /service.ts

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

Rule 3.4.3Store

Add the following code to SRC /store/index.ts:

export const SET_NAV_INDEX_BY_ROUTE = 'setNavIndexByRoute'
Copy the code

Add the following code to store in SRC /store/index.ts:

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)
            }
        }
    }
Copy the code

3.4.4 Viewlayer

Add Archive. Vue file under SRC /views/client and write the following code:

<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>Copy the code

3.4.5 Routerlayer

Define a route to complete route hops. Add code to SRC /route/index.ts file:

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

3.5 about

3.5.1 track of transformationindex.html

<! DOCTYPEhtml>
<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, the maximum - scale = 1.0, user - scalable = no"
          name="viewport"/>
    <title>Talk about little wisdom</title>

    <meta id="referrer" content="always" name="referrer"/>
    <meta content="7XGPmF2RtW" name="baidu-site-verification"/>
    <meta id="keywords" content="A technical product manager for a blog site called" name="keywords"/>
    <meta id="description" content="The blog site of the Falling Clouds. Luo Xia Gu Fan, currently an AI product manager, knows a little about the author of the technical public account, and is committed to the sharing of AI related products and technologies. name="description"/>
</head>
<body>
<div id="app"></div>
<script src="/src/main.ts" type="module"></script>
</body>
</html>
Copy the code

3.5.2 Viewlayer

View the comments by form, in the SRC/views/client/ArticleDetail vue file of 180 ~ 185 lines increased the handling of the about file, the following code:

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

3.5.2 Routerlayer

Define a route to complete route hops. Add code to SRC /route/index.ts file:

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

So far, the blog for the user’s page development is complete.

4. Interface effect

4.1 the home page

4.2 classification

4.3 archive

4.4 Details and about

5. Project code

The project code is submitted according to the section code, and you can see from the submission record how each module was added.

Personal Blog Address: Chat about Small Wisdom (longair.cn)

Front-end code address: gitee.com/Zhou_Jimmy/…

Back end code address: gitee.com/Zhou_Jimmy/…