feat:增加博客统计模块

This commit is contained in:
2025-10-27 15:03:24 +08:00
parent 4d52c2ae82
commit 1ba0e6c40b
9 changed files with 375 additions and 8 deletions

View File

@@ -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
View 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'
})

View 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

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -84,3 +84,11 @@ export interface IBlogNestedComment {
comment: IBlogComment;
replies: IBlogComment[];
}
export interface IBlogOverview {
blogCount: number;
categoryCount: number;
wordCount: number;
greatCount: number;
visitCount: number;
}

View 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>

View File

@@ -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>

View File

@@ -25,6 +25,10 @@ export const metaList: IRouteMetaConfig = {
title: '类别管理',
icon: 'admin-blogCategory'
},
'/blog/unapproved': {
title: '草稿箱',
icon: 'admin-blogUnapproved'
},
'/blog/detail/:id': {
title: '',
icon: '',