一个完整的网站都是有前台和管理后台组成的,前台用来给真正的用户浏览和使用,后台用来给管理员管理网站内容,配置各种功能和数据等。博客的管理后台就是用来承载创建博客,发布博客,查看留言,管理博客用户这些功能的子系统。
大家好,我是落霞孤鹜
,上一篇我们已经实现了管理后台的前端部分页面,这一章我们继续搭建博客的管理后台的前端,实现对博客网站的管理功能。
一、前端开发
1.4 分类和文章管理
文章和分类是关系比较密切的两个业务对象,因此这里把分类管理的功能和文章管理的功能放在同一个页面处理。
1.4.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface Catalog {
id: number,
name: string,
parent: number,
parents: Array<number>,
children: Array<Catalog>
}
export interface Article {
id: number,
title: string,
cover: string,
toc: string,
excerpt: string,
markdown: string,
html: string,
create_at: string,
views: number,
likes: number,
comments: number,
words: number,
tags: Array<number> | any,
tags_info: Array<Tag> | any
catalog: number,
catalog_info: Catalog,
created_at: string,
modified_at: string,
author: string,
status?: string,
}
export interface ArticleArray {
count: number,
results: Array<Article> | any
}
export interface ArticleParams {
title: string | any,
status: string | any,
tags: Array<number> | any,
catalog: number | any,
page: number,
page_size: number,
}
1.4.2 API
层
这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在src/api/service.ts
编写如下代码:
export function getCatalogTree() {
return request({
url: '/catalog/',
method: 'get',
}) as unknown as Array<Catalog>
}
export function saveCatalog(method: string, data: Catalog) {
let url = '/catalog/'
if (['put', 'patch'].includes(method)) {
url += data.id + '/'
}
// @ts-ignore
return request({
url,
method,
data,
}) as unknown as ResponseData
}
export function deleteCatalog(catalogId: number) {
return request({
url: '/catalog/' + catalogId + '/',
method: 'delete',
}) as unknown as ResponseData
}
export function getArticleList(params: ArticleParams) {
return request({
url: '/list/',
method: 'get',
params
}) as unknown as ArticleArray
}
export function remoteDeleteArticle(articleId: number) {
return request({
url: '/article/' + articleId + '/',
method: 'delete',
}) as unknown as ResponseData
}
export function getArticleDetail(articleId: number) {
return request({
url: '/article/' + articleId + '/',
method: 'get',
}) as unknown as Article
}
export function remoteSaveArticle(method: string, data: Article) {
let url = '/article/'
if (['put', 'patch'].includes(method)) {
url += data.id + '/'
}
// @ts-ignore
return request({
url,
method,
data,
}) as unknown as Article
}
export function remotePublishArticle(articleId: number) {
// @ts-ignore
return request({
url: '/publish/' + articleId + '/',
method: 'patch',
}) as unknown as Article
}
export function remoteOfflineArticle(articleId: number) {
return request({
url: '/offline/' + articleId + '/',
method: 'patch',
}) as unknown as Article
}
1.4.3 Component
层
提供一个管理分类的抽屉组件,因此在src/components
下创建文件CatalogTree.vue
,编写代码如下:
<template>
<el-drawer
v-model="state.visible"
:before-close="handleClose"
direction="rtl"
size="500px"
title="目录管理"
@opened="handleSearch"
>
<div class="drawer-content">
<el-tree
:data="state.catalogs"
:expand-on-click-node="false"
:props="defaultProps"
default-expand-all
node-key="id">
<template #default="{ node, data }">
<span class="custom-tree-node">
<span>{{ node.label }}</span>
<span>
<el-dropdown trigger="click">
<span class="el-dropdown-link">
<i class="el-icon-more"/>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="el-icon-edit">
<a class="more-button" @click.prevent="showEditDialog(data)">
修改
</a>
</el-dropdown-item>
<el-dropdown-item icon="el-icon-circle-plus">
<a class="more-button" @click.prevent="showAddDialog(data)">
新增
</a>
</el-dropdown-item>
<el-dropdown-item icon="el-icon-delete-solid">
<el-popconfirm :title="'确定删除【'+data.name+'】?'" cancelButtonText='取消' confirmButtonText='删除'
icon="el-icon-info" iconColor="red" @confirm="remove(data)">
<template #reference>
<a class="more-button">
删除
</a>
</template>
</el-popconfirm>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</span>
</template>
</el-tree>
</div>
</el-drawer>
<el-dialog v-model="state.showDialog" :title="state.dialogTitle">
<el-form class="form" label-suffix=":" label-width="120px" size="medium">
<el-form-item label="目录名称">
<el-input v-model="state.catalog.name" autocomplete="off"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showDialog=false">取 消</el-button>
<el-button :loading="state.loading" type="primary" @click="saveCatalog">保 存</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Catalog} from "../types";
import {deleteCatalog, getCatalogTree, saveCatalog} from "../api/service";
import {ElMessage} from "element-plus";
export default defineComponent({
name: "CatalogTree",
props: {
visible: {
type: Boolean,
require: true,
}
},
watch: {
'$props.visible': {
handler(val, oldVal) {
if (val != oldVal) {
this.state.visible = val
}
}
}
},
emits: ['close',],
setup(props) {
const state = reactive({
catalogs: [] as Array<Catalog>,
visible: props.visible,
showDialog: false,
catalog: {} as Catalog,
dialogTitle: '',
loading: false,
})
const handleSearch = async () => {
state.catalogs = await getCatalogTree();
}
const defaultProps = {
children: 'children',
label: 'name',
}
return {
state,
handleSearch,
defaultProps
}
},
methods: {
handleClose() {
this.$emit('close')
},
showAddDialog(data: Catalog) {
this.state.showDialog = true
//@ts-ignore
this.state.catalog.id = undefined
//@ts-ignore
this.state.catalog.name = undefined
this.state.catalog.parent = data.id
this.state.dialogTitle = '新增目录'
},
showEditDialog(data: Catalog) {
this.state.showDialog = true
this.state.catalog = data
this.state.dialogTitle = '修改目录'
},
async saveCatalog() {
try {
this.state.loading = true
const method = this.state.catalog.id ? 'patch' : 'post'
await saveCatalog(method, this.state.catalog)
this.state.loading = false
this.state.showDialog = false
ElMessage({
message: '保存成功',
type: 'success'
})
await this.handleSearch()
} catch (e) {
console.error(e)
ElMessage({
message: '保存失败',
type: 'error'
})
this.state.loading = false
}
},
async remove(data: Catalog) {
await deleteCatalog(data.id)
ElMessage({
message: '删除成功',
type: 'success'
})
await this.handleSearch()
}
}
})
</script>
<style lang="less" scoped>
.drawer-content {
padding: 12px 0 0 24px;
border-top: #eeeeee 1px solid;
overflow: auto;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 32px;
}
.add-button {
margin-bottom: 12px;
}
</style>
由于文章管理的界面需要有Markdown编辑器,因此安装markdown编辑器的依赖
yarn add @kangc/v-md-editor@2.3.5
yarn add highlight.js@10.7.2
在main.ts
中增加编辑器的 js
、css
和插件
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import { StateKey, store } from "./store";
import 'element-plus/lib/theme-chalk/index.css';
import 'element-plus/lib/theme-chalk/base.css';
// @ts-ignore
import VMdEditor from '@kangc/v-md-editor';
import '@kangc/v-md-editor/lib/style/base-editor.css';
// @ts-ignore
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js';
import '@kangc/v-md-editor/lib/theme/style/github.css';
// highlightjs
import hljs from 'highlight.js';
VMdEditor.use(githubTheme, {
Hljs: hljs,
});
import {
ElAffix,
ElButton,
ElCard,
ElCascader,
ElCol,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElLoading,
ElMenu,
ElMenuItem,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElPopconfirm,
ElProgress,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTimeline,
ElTimelineItem,
ElTooltip,
ElTree,
ElUpload,
} from 'element-plus';
const app = createApp(App)
const components = [
ElAffix,
ElButton,
ElCard,
ElCascader,
ElCol,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElLoading,
ElMenu,
ElMenuItem,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElPopconfirm,
ElProgress,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTimeline,
ElTimelineItem,
ElTooltip,
ElTree,
ElUpload,
]
const plugins = [
ElLoading,
ElMessage,
ElMessageBox,
]
components.forEach(component => {
app.component(component.name, component)
})
plugins.forEach(plugin => {
app.use(plugin)
})
app.use(router).use(store, StateKey).use(VMdEditor).mount('#app')
提供一个编辑文章的抽屉组件,因此在src/components
下创建文件EditArticle.vue
,编写代码如下:
<template>
<el-drawer
v-model="state.visible"
:before-close="handleClose"
:title="articleId?'修改文章':'新增文章'"
direction="rtl"
size="800px"
@opened="handleSearch"
>
<div class="article-form" style="overflow-y: auto">
<el-form label-suffix=":" label-width="120px">
<el-form-item label="标题">
<el-input ref="articleTitle" v-model="state.article.title"></el-input>
</el-form-item>
<el-form-item label="所属分类">
<el-cascader v-model="state.catalogs" :options="state.catalogTree"
:props="{ checkStrictly: true, value:'id',label:'name',expandTrigger: 'hover'}"
clearable
size="medium"
style="width: 100%"/>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="state.article.tags" clearable multiple placeholder="请选择文章标签" size="medium"
style="width: 100%">
<el-option v-for="s in state.tags" :label="s.name" :value="s.id" :key="s.id"/>
</el-select>
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="state.article.excerpt" :rows="5" type="textarea"></el-input>
</el-form-item>
<el-form-item label="正文">
<v-md-editor v-model="state.article.markdown" height="600px"></v-md-editor>
</el-form-item>
<el-form-item label="封面">
<el-upload
:before-upload="beforeAvatarUpload"
:headers="csrfToken"
:on-success="handleAvatarSuccess"
:show-file-list="false"
action="/api/upload/"
class="avatar-uploader"
>
<img v-if="state.article.cover" :src="state.article.cover" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
</el-form>
</div>
<div class="demo-drawer__footer">
<el-button @click="handleClose">取消</el-button>
<el-button :loading="state.loading" type="primary" @click="saveArticle">保存</el-button>
</div>
</el-drawer>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {getArticleDetail, getCatalogTree, getTagList, remoteSaveArticle} from "../api/service";
import {Article, Catalog, Tag, TagList} from "../types";
import {getCookie} from "../utils";
export default defineComponent({
name: "EditArticle",
props: {
articleId: {
type: Number,
require: true,
default: undefined,
},
visible: {
type: Boolean,
require: true,
}
},
watch: {
'$props.visible': {
handler(val: Boolean, oldVal: Boolean) {
if (val !== oldVal) {
this.state.visible = val
}
}
}
},
emits: ["close",],
setup(props, context) {
const state = reactive({
article: {} as Article,
loading: false,
visible: false as Boolean,
catalogTree: [] as Array<Catalog>,
tags: [] as Array<Tag>,
catalogs: [] as Array<number>
})
const saveArticle = async () => {
try {
state.loading = true
if (state.catalogs.length) {
state.article.catalog = state.catalogs[state.catalogs.length - 1]
}
if (props.articleId) {
await remoteSaveArticle('put', state.article)
} else {
await remoteSaveArticle('post', state.article)
}
state.loading = false
context.emit('close', true)
} catch (e) {
state.loading = false
}
}
const csrfToken = {'X-CSRFToken': getCookie('csrftoken')}
return {
state, saveArticle, csrfToken
}
},
methods: {
async handleSearch() {
this.$refs.articleTitle.focus()
if (this.$props.articleId) {
this.state.article = await getArticleDetail(this.$props.articleId)
this.state.article.tags = this.state.article.tags_info.map((tag: Tag) => tag.id)
this.state.catalogs = this.state.article.catalog_info.parents
} else {
this.state.article = {} as Article
}
this.state.catalogTree = await getCatalogTree()
if (!this.state.tags.length) {
const tags: TagList = await getTagList({})
this.state.tags = tags.results
}
},
handleClose(done: any) {
this.$confirm('确认关闭抽屉?', '提示', {
confirmButtonText: '关闭',
cancelButtonText: '取消',
type: 'warning'
})
.then((_: any): void => {
this.$emit("close", false)
this.state.article = {} as Article
done();
})
.catch((_: any): void => {
console.error(_)
});
},
handleAvatarSuccess(res: any, file: File) {
this.state.article.cover = res.url
},
beforeAvatarUpload(file: File) {
const isImage = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'].includes(file.type);
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
this.$message.error('上传图片只能是 JPG 格式!');
}
if (!isLt2M) {
this.$message.error('上传图片大小不能超过 2MB!');
}
return isImage && isLt2M;
}
}
})
</script>
<style lang="less">
.article-form {
padding: 24px;
overflow-y: auto;
border-top: 1px solid #e8e8e8;
height: calc(100% - 100px);
}
//抽屉//element-ui的drawer固定底部按钮
.el-drawer .el-drawer__body{
margin-bottom: 50px ;
height: 100% !important;
}
.el-drawer__header{
margin-bottom: 16px;
}
.demo-drawer__footer {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
border-top: 1px solid #e8e8e8;
padding: 10px 16px;
text-align: right;
background-color: white;
}
//抽屉//去掉element-ui的drawer标题选中状态
:deep(:focus){
outline: 0;
}
.avatar-uploader {
background-color: #fbfdff;
border: 1px dashed #c0ccda;
border-radius: 6px;
box-sizing: border-box;
width: 125px;
height: 100px;
cursor: pointer;
line-height: 100px;
text-align: center;
font-size: 20px;
}
</style>
1.4.4 View
层
通过表格管理文章,通过树形组件管理分类,在src/views/admin
下新增文件Article.vue
文件,编写如下代码:
<template>
<div>
<div>
<el-form :inline="true" class="demo-form-inline">
<el-form-item label="标题">
<el-input ref="title" v-model="state.params.title" placeholder="文章标题"/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="state.params.status" placeholder="状态">
<el-option label="已发布" value="Published"/>
<el-option label="草稿" value="Draft"/>
</el-select>
</el-form-item>
<el-form-item>
<el-button :loading="state.isLoading" type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<div class="button-container">
<el-button :loading="state.isLoading" type="primary" @click="showAddDrawer"><i class="el-icon-plus"/> 新 增
</el-button>
<el-button circle icon="el-icon-s-unfold" @click="state.showCatalogTree=true"/>
</div>
<div>
<el-table ref="articleTable" :data="state.articleList" :header-cell-style="{background:'#eef1f6',color:'#606266'}" stripe
style="width: 100%">
<el-table-column type="selection" width="55"/>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="标题" prop="title" width="200"/>
<el-table-column label="状态" prop="status" width="100"/>
<el-table-column label="分类" prop="catalog_info.name"/>
<el-table-column :formatter="datetimeFormatter" label="修改时间" prop="modified_at"/>
<el-table-column fixed="right" label="操作" width="120">
<template #default="scope">
<el-popconfirm cancelButtonText='取消' confirmButtonText='删除' icon="el-icon-info" iconColor="red"
title="确定删除该文章吗?" @confirm="deleteArticle(scope.$index,scope.row)">
<template #reference>
<el-button size="small" type="text">
删除
</el-button>
</template>
</el-popconfirm>
<el-button size="small" type="text" @click.prevent="showEditDrawer(scope.$index, scope.row)">
编辑
</el-button>
<el-button v-if="scope.row.status==='草稿'" size="small" type="text"
@click.prevent="publishArticle(scope.$index, scope.row)">
发布
</el-button>
<el-button v-else size="small" type="text"
@click.prevent="offlineArticle(scope.$index, scope.row)">
下线
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination :page-size="10" :total="state.total" background
layout="prev, pager, next"></el-pagination>
</div>
</div>
<EditArticle
:article-id="state.articleId"
:visible="state.showDrawer"
@close="handleCloseDrawer"
/>
<CatalogTree
:visible="state.showCatalogTree"
@close="state.showCatalogTree=false"
/>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article, ArticleArray, ArticleParams} from "../../types";
import {getArticleList, remoteDeleteArticle, remoteOfflineArticle, remotePublishArticle} from "../../api/service";
import {timestampToTime} from "../../utils";
import {ElMessage} from "element-plus";
import EditArticle from "../../components/EditArticle.vue";
import CatalogTree from "../../components/CatalogTree.vue";
export default defineComponent({
name: "Article",
components: {CatalogTree, EditArticle},
setup: function () {
const state = reactive({
articleList: [] as Array<Article>,
params: {
title: undefined,
status: undefined,
tags: undefined,
catalog: undefined,
page: 1,
page_size: 10,
} as ArticleParams,
isLoading: false,
total: 0,
showDrawer: false,
articleId: 0,
showCatalogTree: false,
});
const handleSearch = async (): Promise<void> => {
state.isLoading = true;
try {
const data: ArticleArray = await getArticleList(state.params);
state.isLoading = false;
state.articleList = data.results;
state.total = data.count
} catch (e) {
console.error(e)
state.isLoading = false;
}
};
const publishArticle = async (index: number, row: Article) => {
try {
await remotePublishArticle(row.id)
ElMessage({
message: "发布成功!",
type: "success",
});
await handleSearch()
} catch (e) {
console.error(e)
}
}
const offlineArticle = async (index: number, row: Article) => {
try {
await remoteOfflineArticle(row.id)
ElMessage({
message: "下线成功!",
type: "success",
});
await handleSearch()
} catch (e) {
console.error(e)
}
}
const deleteArticle = async (index: number, row: Article) => {
await remoteDeleteArticle(row.id);
ElMessage({
message: "删除成功!",
type: "success",
});
await handleSearch()
}
const datetimeFormatter = (row: Article, column: number, cellValue: string, index: number) => {
return timestampToTime(cellValue, true);
}
handleSearch()
const handleCloseDrawer = (isOk: boolean) => {
state.showDrawer = false
if (isOk) {
handleSearch()
}
}
return {
state,
handleSearch,
datetimeFormatter,
deleteArticle,
handleCloseDrawer,
publishArticle,
offlineArticle
}
},
mounted() {
this.$refs.title.focus()
},
methods: {
showEditDrawer(index: number, row: Article) {
this.$refs.articleTable.setCurrentRow(row)
this.state.showDrawer = true;
this.state.articleId = row.id
},
showAddDrawer() {
this.state.showDrawer = true;
this.state.articleId = 0;
}
}
})
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
1.4.5 Router
层
定义route
来完成路由跳转。在src/route/index.ts
文件中新增代码:
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import("../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import("../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/user',
name: 'UserManagement',
component: () => import("../views/admin/User.vue"),
},
{
path: '/admin/tag',
name: 'Tag',
component: () => import("../views/admin/Tag.vue"),
},
{
path: '/admin/article',
name: 'ArticleManagement',
component: () => import("../views/admin/Article.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
1.4.6 vite.config.ts
由于我们需要展示对上传后的图片,因此需要对上传后的图片代理,在vite.config.ts
文件中,增加如下代理:
'/upload': {
target: 'http://localhost:8000/',
changeOrigin: true,
ws: false,
rewrite: (pathStr) => pathStr.replace('/api', ''),
timeout: 5000,
},
1.5 评论管理
15.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface CommentInfo {
id: number,
user: number,
user_info: User | any,
article: number,
article_info: Article | any,
created_at: string,
reply: number | any,
content: string,
comment_replies: CommentInfo | any,
}
export interface CommentPara {
user: number,
article: number,
reply: number | any,
content: string,
page: number,
page_size: number
}
1.5.2 API
层
这里要处理列表查询。在src/api/service.ts
编写如下代码:
export function getCommentList(params: CommentPara) {
return request({
url: '/comment/',
method: 'get',
params,
}) as unknown as ResponseData
}
1.5.3 Component
层
由于评论无需要做修改删除等操作,只有查看评论详情,因此复用文章详情页面。
1.5.4 View
层
通过表格查看评论,在src/views/admin
下新增文件Comment.vue
文件,编写如下代码:
<template>
<div>
<div>
<el-form :inline="true" :model="state.params" class="demo-form-inline">
<el-form-item label="账号">
<el-select v-model="state.params.user" filterable placeholder="请选择">
<el-option
v-for="item in state.userList"
:key="item.id"
:label="item.nickname || item.username"
:value="item.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="state.params.content" placeholder="评论内容"/>
</el-form-item>
<el-form-item>
<el-button :loading="state.loading" type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<div>
<el-table ref="articleTable" :data="state.commentList" :header-cell-style="{background:'#eef1f6',color:'#606266'}"
stripe>
<el-table-column type="selection" width="55"/>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="评论者" prop="user_info.name" width="200"/>
<el-table-column label="评论内容" prop="content" width="200"/>
<el-table-column label="文章" prop="article_info.title"/>
<el-table-column label="回复评论" prop="reply.id" width="200"/>
<el-table-column :formatter="datetimeFormatter" label="评论时间" prop="created_at"/>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" type="text"
@click.prevent="showDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination :page-size="10" :total="state.total" background
layout="prev, pager, next"></el-pagination>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article, CommentInfo, CommentPara, ResponseData, User} from "../../types";
import {ElMessage} from "element-plus";
import {timestampToTime} from "../../utils";
import {getCommentList, getUserList, saveUser} from "../../api/service";
import UserDetail from "../../components/UserDetail.vue";
export default defineComponent({
name: "Comment",
components: {UserDetail},
setup: function () {
const state = reactive({
commentList: [] as Array<CommentInfo>,
params: {
user: undefined,
article: undefined,
reply: undefined,
content: '',
page: 1,
page_size: 10,
} as unknown as CommentPara,
total: 0,
userList: [] as Array<User>,
loading: false,
});
const handleSearch = async (): Promise<void> => {
state.loading = true;
try {
const data: ResponseData = await getCommentList(state.params);
state.loading = false;
state.commentList = data.results;
state.total = data.count
} catch (e) {
console.error(e)
state.loading = false;
}
};
const getUsers = async (): Promise<void> => {
try {
const data: ResponseData = await getUserList({});
state.userList = data.results;
} catch (e) {
console.error(e)
}
};
const datetimeFormatter = (row: Article, column: number, cellValue: string, index: number) => {
return timestampToTime(cellValue, true);
}
handleSearch()
getUsers()
return {
state,
handleSearch,
datetimeFormatter,
}
},
methods: {
showDetail(row: CommentInfo) {
const {href} = this.$router.resolve({
path: '/article/',
query: {
id: row.article_info.id
}
});
window.open(href, "_blank");
},
}
})
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
1.5.5 Router
层
定义route
来完成路由跳转。在src/route/index.ts
文件中新增代码:
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import(/* webpackChunkName: "login" */ "../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import(/* webpackChunkName: "admin" */ "../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/user',
name: 'UserManagement',
component: () => import("../views/admin/User.vue"),
},
{
path: '/admin/tag',
name: 'Tag',
component: () => import("../views/admin/Tag.vue"),
},
{
path: '/admin/article',
name: 'ArticleManagement',
component: () => import("../views/admin/Article.vue"),
},
{
path: '/admin/comment',
name: 'CommentManagement',
component: () => import("../views/admin/Comment.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
1.6 管理后台首页
1.6.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface NumberInfo {
views: number,
likes: number,
comments: number,
messages: number
}
1.6.2 API
层
这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在src/api/service.ts
编写如下代码:
export function getTopArticleList() {
return request({
url: '/top/',
method: 'get',
}) as unknown as ResponseData
}
export function getNumbers() {
return request({
url: '/number/',
method: 'get',
}) as unknown as NumberInfo
}
1.6.3 Component
层
无需提供额外的组件。
1.6.4 View
层
通过图标和指标卡的形式展示网站的整体情况,修改src/views/admin/Dashboard.vue
,编写如下代码:
<template>
<div>
<div class="title">今日博客访问情况</div>
<el-row :gutter="24" class="numbers">
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-user number-icon"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.views }}</div>
<div>用户访问量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-thumb number-icon" style="background: #64d572;"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.likes }}</div>
<div>点赞量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-chat-line-square number-icon" style="background: #f25e43;"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.comments }}</div>
<div>评论量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-message number-icon" style="background-color: #42B983"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.messages }}</div>
<div>留言量</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div class="top-articles">
<el-card>
<template #header>
文章访问量TOP10
</template>
<div class="article-list">
<div v-for="( article,index) in state.articleList" class="article" @click="viewArticle(article.id)">
<span style="font-size: 14px">{{ index + 1 + '. ' + article.title }}</span>
<span style="color: #999999; font-size: 14px">{{ article.views }} / {{ article.likes }}</span>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article} from "../../types";
import {getNumbers, getTopArticleList} from "../../api/service";
export default defineComponent({
name: "Dashboard",
setup() {
const state = reactive({
numbers: {
views: 0,
likes: 0,
comments: 0,
messages: 0
},
articleList: [{title: 'a', views: 1, likes: 1}] as Array<Article>,
})
return {
state,
}
},
async mounted() {
this.state.articleList = (await getTopArticleList()).results
this.state.numbers = await getNumbers()
},
methods: {
viewArticle(id: number) {
const {href} = this.$router.resolve({
path: '/article/',
query: {
id
}
});
window.open(href, "_blank");
}
}
})
</script>
<style lang="less" scoped>
.numbers {
width: 100%;
}
.title {
color: #999;
margin: 12px 0;
padding-left: 8px;
font-size: 14px;
}
:deep(.el-card__body){
margin: 0;
padding: 0;
}
.number-card {
margin: 0;
padding: 0;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
flex: 1;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 80px;
border: 1px solid #ebeef5;
background-color: #fff;
border-radius: 4px;
overflow: hidden;
}
.number-right {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
text-align: center;
font-size: 14px;
color: #999;
}
.number-num {
font-size: 30px;
font-weight: 700;
color: #2d8cf0;
text-align: center;
}
.number-icon {
font-size: 50px;
width: 80px;
height: 80px;
text-align: center;
line-height: 80px;
color: #fff;
background: #2d8cf0;
}
.top-articles {
margin: 24px 24px 24px 0;
}
.article-list {
padding: 20px;
}
.article {
cursor: pointer;
display: flex;
flex: 1;
justify-content: space-between;
padding: 12px 24px 12px 12px;
border-top: #eeeeee 1px solid;
}
.article:first-child {
border-top: none;
padding-top: 0;
}
.article:last-child {
padding-bottom: 0;
}
.dashboard-list {
display: flex;
flex: 1;
justify-content: space-evenly;
padding: 24px;
margin-right: 24px;;
}
.percentage-value {
display: block;
margin-top: 10px;
font-size: 28px;
}
.percentage-label {
display: block;
margin-top: 10px;
font-size: 12px;
}
</style>
1.6.5Router
层
管理后台已经开发完成,因此需要在路由中做好权限控制,当访问admin路径的时候,需要判断用户是否登录,且用户是否是管理员,因此在src/router/index.ts
中增加如下代码:
router.beforeEach((to, from, next) => {
if (/\/admin/i.test(to.path)
&& (!store.state.user.id ||
store.state.user.role !== 'Admin')) {
next('/login')
return
}
next()
})
在src/views/admin/Login.vue
中第143行后增加一行代码:
is_superuser: data.is_superuser
至此管理后台的前端开发完成
二、前端效果
2.1 前端管理后台页面效果
2.2 前端代码结构
下一篇我们编写博客网站给用户使用的页面。
网友评论