feat:增加博客统计模块
This commit is contained in:
@@ -10,6 +10,12 @@ export const queryBlogApi = (currentPage: number, pageSize: number): Promise<IPa
|
||||
params: { currentPage, pageSize }
|
||||
})
|
||||
|
||||
export const queryUnapprovedBlogApi = (): Promise<IBlog[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/blog/unapproved',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
export const queryBlogByIdApi = (id: number): Promise<IBlog> =>
|
||||
cloudService({
|
||||
url: `/blog-api/blog/${id}/content`,
|
||||
|
||||
54
src/apis/stats.ts
Normal file
54
src/apis/stats.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { cloudService } from './index'
|
||||
|
||||
import { IStatsChart } from 'vue3-common/types'
|
||||
import { IBlogOverview } from '@/types/blog'
|
||||
|
||||
export const queryOverviewStatsApi = (): Promise<IBlogOverview> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/overview',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryCategoryStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/category',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryApprovedMonthlyStatsApi = (year: number): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/approved/monthly',
|
||||
method: 'get',
|
||||
params: { year }
|
||||
})
|
||||
|
||||
|
||||
export const queryVisitMonthlyStatsApi = (year: number): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/visit/monthly',
|
||||
method: 'get',
|
||||
params: { year }
|
||||
})
|
||||
|
||||
|
||||
export const queryVisitRankStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/visit/rank',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryReadRankStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/read/rank',
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
|
||||
export const queryCommentRankStatsApi = (): Promise<IStatsChart[]> =>
|
||||
cloudService({
|
||||
url: '/blog-api/stats/comment/rank',
|
||||
method: 'get'
|
||||
})
|
||||
1
src/icons/svg/admin/blogUnapproved.svg
Normal file
1
src/icons/svg/admin/blogUnapproved.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1761548520883" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1595" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M729.301333 42.666667c79.552 0 142.72 69.504 145.28 154.666666L874.666667 202.666667V576h32a74.666667 74.666667 0 0 1 74.56 70.570667L981.333333 650.666667v256a74.666667 74.666667 0 0 1-74.666666 74.666666h-789.333334A74.666667 74.666667 0 0 1 42.666667 906.666667v-256A74.666667 74.666667 0 0 1 117.333333 576H149.333333V202.666667c0-85.76 61.568-157.034667 140.416-159.914667L294.698667 42.666667h434.602666zM216.832 640H117.333333a10.666667 10.666667 0 0 0-10.666666 10.666667v256a10.666667 10.666667 0 0 0 10.666666 10.666666h789.333334a10.666667 10.666667 0 0 0 10.666666-10.666666v-256a10.666667 10.666667 0 0 0-10.666666-10.666667h-78.421334a10.666667 10.666667 0 0 0-6.912 2.538667l-126.890666 107.733333a74.666667 74.666667 0 0 1-48.32 17.728H381.226667a74.666667 74.666667 0 0 1-52.522667-21.589333l-104.405333-103.317334a10.666667 10.666667 0 0 0-7.488-3.093333zM729.301333 106.666667H294.698667c-42.666667 0-79.104 39.658667-81.28 91.136L213.333333 202.666667V576h3.498667a74.666667 74.666667 0 0 1 49.194667 18.517333l3.328 3.072 104.384 103.317334a10.666667 10.666667 0 0 0 7.509333 3.093333h264.874667a10.666667 10.666667 0 0 0 6.890666-2.538667l126.912-107.733333a74.666667 74.666667 0 0 1 30.72-15.637333L810.666667 202.666667c0-52.202667-35.114667-93.44-77.397334-95.893334L729.301333 106.666667zM629.333333 448a32 32 0 0 1 0 64h-234.666666a32 32 0 0 1 0-64h234.666666z m64-192a32 32 0 0 1 0 64h-362.666666a32 32 0 0 1 0-64h362.666666z" fill="" p-id="1596"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -4,9 +4,17 @@ import {
|
||||
IBlogLatest, IBlogNestedComment, IBlogStats, INewBlog, IQueryBlog
|
||||
} from '@/types/blog'
|
||||
import {
|
||||
addBlogApi, addBlogCommentApi, deleteBlogApi,
|
||||
queryBlogApi, queryBlogByIdApi,
|
||||
queryBlogCategoryApi, queryBlogCommentApi, queryBlogStatsApi, queryLatestBlogApi, updateBlogApi
|
||||
addBlogApi,
|
||||
addBlogCommentApi,
|
||||
deleteBlogApi,
|
||||
queryBlogApi,
|
||||
queryBlogByIdApi,
|
||||
queryBlogCategoryApi,
|
||||
queryBlogCommentApi,
|
||||
queryBlogStatsApi,
|
||||
queryLatestBlogApi,
|
||||
queryUnapprovedBlogApi,
|
||||
updateBlogApi
|
||||
} from '@/apis/blog.ts'
|
||||
import { IHandleApi } from 'vue3-common/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -16,6 +24,7 @@ import { flattenToTwoLevel } from '@/utils/blog/blogUtil.ts'
|
||||
export const useBlogStore = defineStore('blog', {
|
||||
state: () => ({
|
||||
blogList: [] as IBlog[],
|
||||
unapprovedBlogList: [] as IBlog[],
|
||||
blogCommentList: [] as IBlogComment[],
|
||||
blogNestedCommentList: [] as IBlogNestedComment[],
|
||||
currentBlogComment: {
|
||||
@@ -66,7 +75,8 @@ export const useBlogStore = defineStore('blog', {
|
||||
totalSize: 1
|
||||
},
|
||||
isScrollEnd: false,
|
||||
progressValue: '0%'
|
||||
progressValue: '0%',
|
||||
isRefresh: false
|
||||
}),
|
||||
actions: {
|
||||
async queryBlogList() {
|
||||
@@ -75,6 +85,9 @@ export const useBlogStore = defineStore('blog', {
|
||||
this.blogList = result.records
|
||||
this.pageInfo.totalSize = result.total
|
||||
},
|
||||
async queryUnapprovedBlogList() {
|
||||
this.unapprovedBlogList = await queryUnapprovedBlogApi()
|
||||
},
|
||||
async queryBlogById(id: number) {
|
||||
this.currentBlog = await queryBlogByIdApi(id)
|
||||
await this.queryBlogComment(this.currentBlogId)
|
||||
|
||||
@@ -196,7 +196,7 @@ $transition-time: 0.28s;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #FFFFFF;
|
||||
border-radius: 10px;
|
||||
box-shadow: 5px 5px 5px #CFCFCF;
|
||||
box-shadow: 1px 1px 2px #CFCFCF;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,3 +84,11 @@ export interface IBlogNestedComment {
|
||||
comment: IBlogComment;
|
||||
replies: IBlogComment[];
|
||||
}
|
||||
|
||||
export interface IBlogOverview {
|
||||
blogCount: number;
|
||||
categoryCount: number;
|
||||
wordCount: number;
|
||||
greatCount: number;
|
||||
visitCount: number;
|
||||
}
|
||||
|
||||
96
src/views/blog/unapproved.vue
Normal file
96
src/views/blog/unapproved.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="admin-module">
|
||||
<div class="button-container">
|
||||
<el-button type="primary" @click="addNewBlogClick">新增文章</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="blogStore.unapprovedBlogList"
|
||||
border stripe
|
||||
:header-cell-style="tableHeaderStyle()"
|
||||
:cell-style="tableCellStyle()"
|
||||
:row-style="tableRowStyle()"
|
||||
class="card-item">
|
||||
<el-table-column type="index" align="center" width="50" />
|
||||
<el-table-column prop="title" label="标题" align="center" min-width="15%"/>
|
||||
<el-table-column prop="category" label="类别" align="center" min-width="10%" />
|
||||
<el-table-column label="精品" align="center" min-width="5%">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.isGreat">是</el-tag>
|
||||
<el-tag type="danger" v-else>否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="置顶" align="center" min-width="5%">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.topValue > 1">是</el-tag>
|
||||
<el-tag type="danger" v-else>否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="wordCount" label="字数" align="center" min-width="10%" />
|
||||
<el-table-column prop="visitCount" label="阅读次数" align="center" min-width="10%" />
|
||||
<el-table-column label="是否发布" align="center" min-width="10%">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.isApproved">已发布</el-tag>
|
||||
<el-tag type="danger" v-else>草稿</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column fixed="right" label="操作" align="center" width="300">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" @click="previewBlogClick(scope.row)">预览</el-button>
|
||||
<el-button type="primary" @click="editBlogClick(scope.row)">编辑</el-button>
|
||||
<el-button type="danger" @click="deleteBlogClick(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useBlogStore } from '@/store/blog.ts'
|
||||
import { IBlog } from '@/types/blog.ts'
|
||||
import { commonElMessageBox, tableCellStyle, tableHeaderStyle, tableRowStyle } from 'vue3-common/utils/elUtil'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const blogStore = useBlogStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await blogStore.queryUnapprovedBlogList()
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击新增博客按钮
|
||||
*/
|
||||
const addNewBlogClick = () => {
|
||||
router.push({ name: 'BlogDetailId', params: { id: 0 } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击预览博客按钮
|
||||
* @param blog 博客
|
||||
*/
|
||||
const previewBlogClick = async (blog: IBlog) => {
|
||||
await router.push({ name: 'BlogPreviewId', params: { id: blog.id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击编辑博客按钮
|
||||
* @param blog 博客
|
||||
*/
|
||||
const editBlogClick = async (blog: IBlog) => {
|
||||
await router.push({ name: 'BlogDetailId', params: { id: blog.id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击删除博客按钮
|
||||
* @param blog 博客
|
||||
*/
|
||||
const deleteBlogClick = (blog: IBlog) => {
|
||||
blogStore.blogApiType = 'DELETE'
|
||||
|
||||
commonElMessageBox('是否确认删除该博客?').then(() => {
|
||||
blogStore.handleBlogApi(blog.id)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,198 @@
|
||||
<template>
|
||||
<div class="admin-module">
|
||||
Dashboard
|
||||
<div class="admin-module blog-dashboard">
|
||||
<div class="blog-overview">
|
||||
<el-statistic class="card-item" title="博客总数" :value="blogDashboard.overview.blogCount" />
|
||||
|
||||
<el-statistic class="card-item" title="类别总数" :value="blogDashboard.overview.categoryCount" />
|
||||
|
||||
<el-statistic class="card-item" title="字数统计" :value="blogDashboard.overview.wordCount" />
|
||||
|
||||
<el-statistic class="card-item" title="访问统计" :value="blogDashboard.overview.visitCount" />
|
||||
</div>
|
||||
|
||||
<div class="blog-chart">
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊每月博客发布统计</span>
|
||||
</div>
|
||||
<div class="chart" ref="approvedMonthlyChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊每月博客访问统计</span>
|
||||
</div>
|
||||
<div class="chart" ref="visitMonthlyChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客访问数量排行</span>
|
||||
</div>
|
||||
<div class="chart" ref="visitRankChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客类别统计</span>
|
||||
</div>
|
||||
<div class="chart" ref="categoryChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客阅读时长排行</span>
|
||||
</div>
|
||||
<div class="chart" ref="readRankChartRef"/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container card-item">
|
||||
<div class="content">
|
||||
<span class="title">📊博客评论数量排行</span>
|
||||
</div>
|
||||
<div class="chart" ref="commentRankChartRef"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { barChartOptions, lineChartOptions, pieChartOptions } from 'vue3-common/utils/eChartsUtil'
|
||||
import { useAppStore } from '@/store/app.ts'
|
||||
import {
|
||||
queryApprovedMonthlyStatsApi, queryCategoryStatsApi, queryCommentRankStatsApi, queryOverviewStatsApi,
|
||||
queryReadRankStatsApi,
|
||||
queryVisitMonthlyStatsApi,
|
||||
queryVisitRankStatsApi
|
||||
} from '@/apis/stats'
|
||||
import { IBlogOverview } from '@/types/blog'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const approvedMonthlyChartRef = ref<HTMLElement>()
|
||||
const visitMonthlyChartRef = ref<HTMLElement>()
|
||||
const visitRankChartRef = ref<HTMLElement>()
|
||||
const categoryChartRef = ref<HTMLElement>()
|
||||
const readRankChartRef = ref<HTMLElement>()
|
||||
const commentRankChartRef = ref<HTMLElement>()
|
||||
|
||||
let approvedMonthlyChart: echarts.ECharts
|
||||
let visitMonthlyChart: echarts.ECharts
|
||||
let visitRankChart: echarts.ECharts
|
||||
let categoryChart: echarts.ECharts
|
||||
let readRankChart: echarts.ECharts
|
||||
let commentRankChart: echarts.ECharts
|
||||
|
||||
const blogDashboard = reactive({
|
||||
overview: {
|
||||
blogCount: 0,
|
||||
categoryCount: 0,
|
||||
wordCount: 0,
|
||||
greatCount: 0,
|
||||
visitCount: 0
|
||||
} as IBlogOverview
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
approvedMonthlyChart = echarts.init(approvedMonthlyChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
visitMonthlyChart = echarts.init(visitMonthlyChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
visitRankChart = echarts.init(visitRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
categoryChart = echarts.init(categoryChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
readRankChart = echarts.init(readRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
commentRankChart = echarts.init(commentRankChartRef.value as HTMLElement, appStore.isDark ? 'dark' : null)
|
||||
|
||||
updateChart()
|
||||
})
|
||||
|
||||
const updateChart = async () => {
|
||||
blogDashboard.overview = await queryOverviewStatsApi()
|
||||
|
||||
const approvedMonthlyData = await queryApprovedMonthlyStatsApi(2025)
|
||||
const approvedXAxis = approvedMonthlyData.map((item) => item.name)
|
||||
const approvedYAxis = approvedMonthlyData.map((item) => item.value)
|
||||
approvedMonthlyChart.setOption(lineChartOptions('日期', approvedXAxis, '数量(篇)', approvedYAxis))
|
||||
|
||||
const visitMonthlyData = await queryVisitMonthlyStatsApi(2025)
|
||||
const visitMonthlyXAxis = visitMonthlyData.map((item) => item.name)
|
||||
const visitMonthlyYAxis = visitMonthlyData.map((item) => item.value)
|
||||
visitMonthlyChart.setOption(lineChartOptions('日期', visitMonthlyXAxis, '访问量(人)', visitMonthlyYAxis))
|
||||
|
||||
const visitRankData = await queryVisitRankStatsApi()
|
||||
const visitRankXAxis = visitRankData.map((item) => item.name)
|
||||
const visitRankYAxis = visitRankData.map((item) => item.value)
|
||||
visitRankChart.setOption(barChartOptions('名称', visitRankXAxis, '访问量(人)', visitRankYAxis))
|
||||
|
||||
const categoryData = await queryCategoryStatsApi()
|
||||
categoryChart.setOption(pieChartOptions('类别', '篇', categoryData))
|
||||
|
||||
const readRankData = await queryReadRankStatsApi()
|
||||
const readRankXAxis = readRankData.map((item) => item.name)
|
||||
const readRankYAxis = readRankData.map((item) => item.value)
|
||||
readRankChart.setOption(barChartOptions('名称', readRankXAxis, '时长(分钟)', readRankYAxis))
|
||||
|
||||
const commentRankData = await queryCommentRankStatsApi()
|
||||
const commentRankXAxis = commentRankData.map((item) => item.name)
|
||||
const commentRankYAxis = commentRankData.map((item) => item.value)
|
||||
commentRankChart.setOption(barChartOptions('名称', commentRankXAxis, '评论量(个)', commentRankYAxis))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss">
|
||||
.blog-dashboard {
|
||||
overflow: auto;
|
||||
|
||||
.blog-overview {
|
||||
width: 100%;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
|
||||
.el-statistic {
|
||||
padding: 10px;
|
||||
|
||||
.el-statistic__head {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.el-statistic__number {
|
||||
font-size: xx-large;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blog-chart {
|
||||
flex-grow: 1;
|
||||
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
|
||||
.chart-container {
|
||||
display: grid;
|
||||
grid-template-rows: 40px 1fr;
|
||||
|
||||
.content {
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #009FA8;
|
||||
|
||||
.title {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,10 @@ export const metaList: IRouteMetaConfig = {
|
||||
title: '类别管理',
|
||||
icon: 'admin-blogCategory'
|
||||
},
|
||||
'/blog/unapproved': {
|
||||
title: '草稿箱',
|
||||
icon: 'admin-blogUnapproved'
|
||||
},
|
||||
'/blog/detail/:id': {
|
||||
title: '',
|
||||
icon: '',
|
||||
|
||||
Reference in New Issue
Block a user