一個完整的網(wǎng)站都是有前臺和管理后臺組成的,前臺用來給真正的用戶瀏覽和使用焙畔,后臺用來給管理員管理網(wǎng)站內(nèi)容掸读,配置各種功能和數(shù)據(jù)等。博客的管理后臺就是用來承載創(chuàng)建博客宏多,發(fā)布博客儿惫,查看留言,管理博客用戶這些功能的子系統(tǒng)伸但。
大家好肾请,我是落霞孤鶩
,上一篇我們已經(jīng)實現(xiàn)了管理后臺的后端接口部分更胖,這一章我們開始搭建博客的管理后臺的前端铛铁,實現(xiàn)對博客網(wǎng)站的管理功能。
一却妨、前端界面開發(fā)
一個管理后臺的功能饵逐,一般都需要從最基礎(chǔ)的業(yè)務(wù)對象的管理開始,在我們的博客網(wǎng)站上彪标,業(yè)務(wù)對象間的依賴依次是用戶倍权、標簽、分類捐下、文章账锹、評論、點贊坷襟、留言奸柬、首頁統(tǒng)計。
基于這個依賴關(guān)系婴程,我們的后臺管理功能也按照這樣的邏輯順序進行構(gòu)建廓奕。然后在構(gòu)建每一個業(yè)務(wù)對象的管理頁面時,按照Type
档叔、API
桌粉、Component
、View
衙四、Route
順序進行組織和代碼編寫铃肯。
在src/views
下創(chuàng)建兩個文件夾admin
和client
,并把上一個章節(jié)中創(chuàng)建的Login.vue
移動到admin
文件夾传蹈,把Home.vue
文件移動到client
下押逼。
1.1 菜單管理
1.1.1 Admin.vue
管理后臺的功能需要一個獨立的菜單導(dǎo)航功能步藕,因此在src/views/admin
下新增Admin.vue
文件,用于完成左側(cè)的菜單導(dǎo)航挑格,代碼如下:
<template>
<div class="body">
<div class="menu">
<el-menu :default-active="state.activePath" :router="true">
<el-menu-item index="AdminDashboard" route="/admin/dashboard"><i class="el-icon-s-home"></i> Dashboard
</el-menu-item>
<el-menu-item index="ArticleManagement" route="/admin/article"><i class="el-icon-s-order"></i> 文章
</el-menu-item>
<el-menu-item index="TagManagement" route="/admin/tag"><i class="el-icon-collection-tag"></i> 標簽</el-menu-item>
<el-menu-item index="CommentManagement" route="/admin/comment"><i class="el-icon-chat-line-round"></i> 評論
</el-menu-item>
<el-menu-item index="UserManagement" route="/admin/user"><i class="el-icon-user"></i> 用戶</el-menu-item>
</el-menu>
</div>
<div class="view">
<router-view/>
</div>
</div>
</template>
<script>
import {defineComponent, reactive} from "vue";
import {useRoute} from "vue-router";
export default defineComponent({
name: "Admin",
setup() {
const state = reactive({
activePath: '',
});
const route = useRoute()
if (route.name === 'Dashboard') {
state.activePath = 'AdminDashboard'
} else {
state.activePath = route.name;
}
return {
state,
}
},
});
</script>
<style lang="less" scoped>
.body {
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
}
.user {
font-size: 20px;
}
.menu {
width: 200px;
}
.view {
width: calc(100% - 200px);
padding: 24px;
}
.el-menu {
height: 100%;
}
</style>
3.1.2 Dashboard.vue
為了接下來的開發(fā)能很好的開展咙冗,我們先處理管理后臺的默認頁面Dashboard
, 在src/views/admin
下創(chuàng)建文件Dashboard.vue
漂彤,編寫代碼:
<template>
<h3>Dashboard</h3>
</template>
<script lang="ts">
import { defineComponent, reactive } from "vue";
export default defineComponent({
name: 'Dashboard',
})
</script>
3.1.3 添加路由
在src/router/index.ts
下調(diào)整代碼如下:
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"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
1.2 用戶管理
1.2.1 Type
層
在我們處理登錄和注冊的時候雾消,已經(jīng)完成了用戶的類型定義,也即User
的interface定義挫望,這里增加所有返回結(jié)果的定義立润,用于管理接口返回的數(shù)據(jù)結(jié)構(gòu)。在src/types/index.ts
文件中代碼如下:
export interface User {
id: number,
username: string,
email: string,
avatar: string | any,
nickname: string | any,
is_active?: any,
is_superuser?: boolean,
created_at?: string,
}
export interface ResponseData {
count: number;
results?: any;
detail?: string;
}
1.2.2 API
層
這里要編寫用戶管理相關(guān)的接口媳板,列表查詢范删、啟用、禁用拷肌、詳情查看。在src/api/service.ts
編寫如下代碼:
import { User, ResponseData } from "../types"
export function getUserDetail(userId: number) {
return request({
url: '/user/' + userId + '/',
method: 'get',
}) as unknown as User
}
export function saveUser(method: string, data: User) {
// @ts-ignore
return request({
url: '/user/' + data.id + '/',
method,
data,
}) as unknown as ResponseData
}
1.2.3 Component
層
在查看用戶詳情時旨巷,我們需要一個抽屜巨缘,展示用戶的詳細信息,因此在src/components
下創(chuàng)建文件UserDetail.vue
采呐,編寫代碼如下:
<template>
<el-drawer
v-model="state.visible"
:before-close="handleClose"
direction="rtl"
size="500px"
title="用戶詳情"
@opened="handleSearch"
>
<el-descriptions :column="1" border class="detail" >
<el-descriptions-item label="用戶名">{{ state.user.username }}</el-descriptions-item>
<el-descriptions-item label="角色">{{ state.user.role }}</el-descriptions-item>
<el-descriptions-item label="狀態(tài)">{{ state.user.is_active }}</el-descriptions-item>
<el-descriptions-item label="郵箱">{{ state.user.email }}</el-descriptions-item>
<el-descriptions-item label="創(chuàng)建時間">{{ state.user.created_at }}</el-descriptions-item>
<el-descriptions-item label="最后登錄時間">{{ state.user.last_login }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {User} from "../types";
import {getUserDetail} from "../api/service";
export default defineComponent({
name: "UserDetail",
props: {
visible: {
type: Boolean,
require: true,
},
userId: {
type: Number,
require: true,
},
loading: {
type: Boolean,
require: true,
}
},
emits: ["close",],
watch: {
'$props.visible': {
async handler(val: Boolean, oldVal: Boolean) {
if (val !== oldVal) {
this.state.visible = val
}
}
}
},
setup(props) {
const state = reactive({
visible: props.visible as Boolean,
user: {} as User,
});
return {
state,
}
},
methods: {
handleClose(isOk: Boolean) {
this.$emit("close", {
user: this.state.user,
isOk,
})
},
async handleSearch() {
this.state.user = await getUserDetail(this.$props.userId)
}
}
})
</script>
<style scoped>
.detail {
padding: 24px;
margin-top: -12px;
border-top: #eeeeee 1px solid;
}
</style>
1.2.4 View
層
在用戶管理中若锁,我們通過一個表格,分頁展示所有的用戶信息斧吐,并通過表格的操作列又固,提供查看詳情、啟用煤率、禁用功能仰冠。
在src/utils/index.ts
下增加方法timestampToTime
export function timestampToTime(timestamp: Date | any, dayMinSecFlag: boolean) { const date = new Date(timestamp); const Y = date.getFullYear() + "-"; const M = (date.getMonth() + 1 < 10 ? "0" + (date.getMonth() + 1) : date.getMonth() + 1) + "-"; const D = date.getDate() < 10 ? "0" + date.getDate() + " " : date.getDate() + " "; const h = date.getHours() < 10 ? "0" + date.getHours() + ":" : date.getHours() + ":"; const m = date.getMinutes() < 10 ? "0" + date.getMinutes() + ":" : date.getMinutes() + ":"; const s = date.getSeconds() < 10 ? "0" + date.getSeconds() : date.getSeconds(); if (!dayMinSecFlag) { return Y + M + D; } return Y + M + D + h + m + s;}
在src/views/admin
下新增文件User.vue
,引用UserDetail
組件蝶糯,具體代碼如下:
<template>
<div>
<div>
<el-form :inline="true" :model="state.params" class="demo-form-inline">
<el-form-item label="名稱">
<el-input v-model="state.params.name" placeholder="賬號"/>
</el-form-item>
<el-form-item label="狀態(tài)">
<el-select v-model="state.params.is_active" placeholder="請選擇">
<el-option :value="1" label="生效"/>
<el-option :value="0" label="禁用"/>
</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>
<el-table ref="userTable" :data="state.userList" :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="username" width="200"/>
<el-table-column label="昵稱" prop="nickname" width="200"/>
<el-table-column label="狀態(tài)" prop="is_active"/>
<el-table-column :formatter="datetimeFormatter" label="注冊時間" prop="created_at"/>
<el-table-column label="操作">
<template #default="scope">
<el-popconfirm v-if="scope.row.is_active" cancelButtonText='取消' confirmButtonText='禁用' icon="el-icon-info"
iconColor="red" title="確定禁用該用戶嗎洋只?" @confirm="disableUser(scope.$index,scope.row)">
<template #reference>
<el-button size="small" type="text">
禁用
</el-button>
</template>
</el-popconfirm>
<el-button v-if="!scope.row.is_active" size="small" type="text"
@click.native.prevent="enableUser(scope.$index, scope.row)">
啟用
</el-button>
<el-button size="small" type="text"
@click.native.prevent="showUserDetail(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>
<UserDetail
:user-id="state.userId"
:visible="state.showDialog"
@close="state.showDialog = false"
/>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {ResponseData, User} from "../../types";
import {ElMessage} from "element-plus";
import {timestampToTime} from "../../utils";
import {getUserList, saveUser} from "../../api/service";
import UserDetail from "../../components/UserDetail.vue";
export default defineComponent({
name: "User",
components: {UserDetail},
setup: function () {
const state = reactive({
userList: [] as Array<User>,
params: {
name: '',
role: 'Reader',
is_active: undefined,
page: 1,
page_size: 10,
},
isLoading: false,
total: 0,
showDialog: false,
userId: 0,
saveLoading: false,
});
const handleSearch = async (): Promise<void> => {
state.isLoading = true;
try {
const data: ResponseData = await getUserList(state.params);
state.isLoading = false;
state.userList = data.results;
state.total = data.count
} catch (e) {
console.error(e)
state.isLoading = false;
}
};
const disableUser = async (index: number, row: User) => {
await saveUser('patch', {id: row.id, is_active: false} as User);
ElMessage({
message: "禁用成功!",
type: "success",
});
await handleSearch()
}
const enableUser = async (index: number, row: User) => {
await saveUser('patch', {id: row.id, is_active: true} as User);
ElMessage({
message: "啟用成功昼捍!",
type: "success",
});
await handleSearch()
}
const datetimeFormatter = (row: User, column: number, cellValue: string, index: number) => {
return timestampToTime(cellValue, true);
}
handleSearch()
return {
state,
handleSearch,
datetimeFormatter,
disableUser,
enableUser,
}
},
methods: {
showUserDetail(row: User) {
this.state.userId = row.id
this.state.showDialog = true;
},
}
})
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
1.2.5 Router
層
有了一個新的頁面识虚,我們需要定義route
來完成路由跳轉(zhuǎn)。在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"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
1.3 標簽管理
主要是為了方便靈活的給文章標記類型妒茬,所以才有標簽管理担锤,標簽的屬性很簡單,就是一個名稱乍钻。
1.3.1 Type
層
在src/types/index.ts
文件中增加代碼如下:
export interface Tag {
id: number,
name: string,
created_at: string,
modified_at: string,
}
export interface TagList {
count: number,
results: Array<Tag> | any
}
1.3.2 API
層
這里要編寫標簽管理相關(guān)的接口肛循,列表查詢铭腕、新增、修改育拨、刪除谨履。在src/api/service.ts
編寫如下代碼:
export function getTagList(params: any) {
return request({
url: '/tag/',
method: 'get',
params,
}) as unknown as TagList
}
export function saveTag(method: string, data: Tag) {
let url = '/tag/'
if (['put', 'patch'].includes(method)) {
url += data.id + '/'
}
// @ts-ignore
return request({
url,
method,
data,
}) as unknown as ResponseData
}
export function addTag(data: Tag) {
return request({
url: '/tag/',
method: 'post',
data,
}) as unknown as ResponseData
}
export function deleteTag(id: number) {
return request({
url: '/tag/' + id + '/',
method: 'delete',
}) as unknown as ResponseData
}
1.3.3 Component
層
提供一個新增和修改標簽的彈框組件,因此在src/components
下創(chuàng)建文件TagEditDialog.vue
熬丧,編寫代碼如下:
<template>
<el-dialog v-model="state.visible" :title="state.title" @close="handleClose(false)" width="440px" >
<el-form size="medium" label-suffix=":" class="form">
<el-form-item label="名稱" label-width="80px">
<el-input v-model="state.name" autocomplete="off" size=""></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose(false)">取 消</el-button>
<el-button :loading="loading" type="primary" @click="handleClose(true)">確 定</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts">
import {defineComponent, PropType, reactive} from "vue";
import {Tag} from "../types";
export default defineComponent({
name: "TagEditDialog",
props: {
visible: {
type: Boolean,
require: true,
},
tag: {
type: Object as PropType<Tag>,
require: true,
},
loading: {
type: Boolean,
require: true,
}
},
emits: ["close",],
watch: {
'$props.visible': {
handler(val: Boolean, oldVal: Boolean) {
if (val !== oldVal) {
this.state.visible = val
}
if (val) {
this.state.name = this.$props.tag.name
this.state.title = this.$props.tag.id ? '修改標簽' : '新增標簽'
}
}
}
},
setup(props) {
const state = reactive({
visible: props.visible as Boolean,
//@ts-ignore
name: '',
//@ts-ignore
title: ''
});
return {
state,
}
},
methods: {
handleClose(isOk: Boolean) {
this.$emit("close", {
obj: {
//@ts-ignore
id: this.$props.tag.id,
name: this.state.name
},
isOk,
})
}
}
})
</script>
<style scoped>
.form{
padding-right: 24px;
}
</style>
1.3.4 View
層
通過表格管理標簽笋粟,實現(xiàn)對標簽的新增,修改析蝴,刪除和列表查看害捕,在src/views/admin
下新增文件Tag.vue
文件,編寫如下代碼:
<template>
<div>
<div>
<el-form :inline="true" :model="state.params" class="demo-form-inline">
<el-form-item label="名稱">
<el-input v-model="state.params.name" placeholder="名稱" />
</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="showAddDialog"
><i class="el-icon-plus" /> 新 增
</el-button>
</div>
<div>
<el-table
ref="tagTable"
:data="state.tagList"
: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="name" width="200" />
<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="deleteObject(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="showEditDialog(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>
<TagEditDialog
:loading="state.saveLoading"
:tag="state.tag"
:visible="state.showDialog"
@close="handleCloseDialog"
/>
</template>
<script lang="ts">
import { defineComponent, reactive } from "vue";
import { ResponseData, Tag } from "../../types";
import { addTag, deleteTag, getTagList, saveTag } from "../../api/service";
import { timestampToTime } from "../../utils";
import { ElMessage } from "element-plus";
import TagEditDialog from "../../components/TagEditDialog.vue";
import { useRoute } from "vue-router";
export default defineComponent({
name: "Tag",
components: { TagEditDialog },
watch: {
"$route.path": {
handler(val, oldVal) {
if (val !== oldVal && ["/admin/tag"].includes(val)) this.handleSearch();
},
deep: true,
},
},
setup: function () {
const route = useRoute();
const state = reactive({
tagList: [] as Array<Tag>,
params: {
name: undefined,
page: 1,
page_size: 10,
},
isLoading: false,
total: 0,
showDialog: false,
tag: {
id: 0,
name: "",
} as Tag,
saveLoading: false,
});
const handleSearch = async (): Promise<void> => {
state.isLoading = true;
try {
const data: ResponseData = await getTagList(state.params);
state.isLoading = false;
state.tagList = data.results;
state.total = data.count;
} catch (e) {
console.error(e);
state.isLoading = false;
}
};
const deleteObject = async (index: number, row: Tag) => {
await deleteTag(row.id);
ElMessage({
message: "刪除成功尝盼!",
type: "success",
});
await handleSearch();
};
const datetimeFormatter = (
row: Tag,
column: number,
cellValue: string,
index: number
) => {
return timestampToTime(cellValue, true);
};
handleSearch();
return {
state,
handleSearch,
datetimeFormatter,
deleteObject,
};
},
methods: {
showEditDialog(index: number, row: Tag) {
this.state.tag = row;
this.state.showDialog = true;
},
showAddDialog() {
this.state.tag = {} as Tag;
this.state.showDialog = true;
},
async handleCloseDialog(params: any) {
if (!params.isOk) {
this.state.showDialog = false;
return;
}
this.state.saveLoading = true;
const method = this.state.tag.id ? "put" : "post";
try {
await saveTag(method, params.obj);
this.state.showDialog = false;
this.state.saveLoading = false;
await this.handleSearch();
} catch (e) {
console.error(e);
this.state.saveLoading = false;
}
},
},
});
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
1.3.5Router
層
定義route
來完成路由跳轉(zhuǎn)。在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"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;