- 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
- Vue3+TypeScript+Django Rest Fram
- Vue3+TypeScript+Django Rest Fram
- Vue3+TypeScript+Django Rest Fram
- Vue3+TypeScript+Django Rest Fram
- Rest-framework-API参考-GenericAPIV
博客网站最重要的是有一个给用户浏览文章的页面,也就是博客网站的前台,用户通过这个页面可以查找文章,浏览文章详情,评论,点赞等。
大家好,我是落霞孤鹜
,上一篇我们已经博客的管理后台功能,这一章我们开始搭建博客的前台,实现对博客网站文章的查看,浏览,评论,点赞等功能。我同样按照一个完整的功能,从需求分析到代码编写来阐述如何实现。
一、需求分析
作为一个完整的博客网站,前台是内容呈现的核心部分,大部分的博客搭建文章着重介绍的也是这一部分。这里我们从实际需要出发,整理了如下需求要点:
- 首页: 主要展示整个博客网站的文章,一般按照发布时间倒序呈现,展示标题,摘要,浏览量,点赞量,评论量,留言量等内容,提供标签筛选
- 文章详情:主要用来展示文章的详情,涵盖文章的所有细节,同时提供文章的章节目录导航、点赞、评论功能。
- 文章分类:通过分类呈现文章列表,方便用户通过类型快速查找感兴趣的文章。
- 归档:按照年份倒序呈现博客网站的文章列表。
- 关于:一般介绍博客的博主情况和博客网站的主题信息。
以上功能也算是一套简单的个人博客网站的核心功能框架。
二、后端接口部分
后端接口部分在上一篇的管理后端中,已经全部实现,这里就不再重复介绍。
三、前端界面部分
前端按照需求,我们从首页,文章详情,文章分类,归档,关于五个部分呈现。这一部分的页面全部放在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层
在文章列表中,我们为了更好的体验,对图片展示提供了限流处理和无极滚动加载。工具方法有:getDocumentHeight
, getQueryStringByName
, getScrollTop
, getWindowHeight
, throttle
,在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.vue
的style
部分,增加导入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 首页
![](https://img.haomeiwen.com/i8945890/a671c247e05eea22.png)
4.2 分类
![](https://img.haomeiwen.com/i8945890/f3cef56458d8dda0.png)
4.3 归档
![](https://img.haomeiwen.com/i8945890/63a9f221a9b0486c.png)
4.4 文章详情和关于
![](https://img.haomeiwen.com/i8945890/c1cf853bd4b1bc2f.png)
五、项目代码
项目的代码按照章节的代码进行的提交,能从提交记录中看到每一个模块是如何添加进去的。
个人博客地址:微谈小智 (longair.cn)
网友评论