feat: 美化统计页面布局

- 新增顶部统计卡片:文章总数、分类数量、最早发布、高频词汇
- 图表卡片化设计,统一圆角、阴影、边框样式
- 优化 ECharts 配色,使用 Element Plus 主题色系
- 图表添加标题、副标题和图标
- 支持响应式布局(大屏/中屏/小屏适配)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
灌糖包子 2026-03-20 22:27:35 +08:00
parent 1e7748db64
commit 9131148bc1
Signed by: sookie
GPG Key ID: 0599BECB75C1E68D

View File

@ -1,16 +1,91 @@
<template> <template>
<div> <div class="statistics-page">
<div class="echarts-container"> <!-- 顶部统计卡片 -->
<div ref="categoriesChart" v-loading="categoriesChartLoading"></div> <div class="stat-cards">
<div ref="publishDatesChart" v-loading="publishDatesChartLoading"></div> <div class="stat-card">
<div class="timeline-chart" ref="timelineWordsChart" v-loading="timelineWordsChartLoading"></div> <div class="stat-icon" style="background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">文章总数</div>
<div class="stat-value">{{ totalArticles }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<el-icon><FolderOpened /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">分类数量</div>
<div class="stat-value">{{ totalCategories }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<el-icon><Calendar /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">最早发布</div>
<div class="stat-value">{{ earliestDate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background-image: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="stat-info">
<div class="stat-label">高频词汇</div>
<div class="stat-value">{{ topKeyword }}</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-grid">
<!-- 文章分类 -->
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">
<el-icon><PieChart /></el-icon>
<span>文章分类分布</span>
</div>
<div class="chart-subtitle">各类别文章数量占比</div>
</div>
<div ref="categoriesChart" v-loading="categoriesChartLoading" class="chart-body"></div>
</div>
<!-- 发布时间趋势 -->
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">
<el-icon><TrendCharts /></el-icon>
<span>文章发布趋势</span>
</div>
<div class="chart-subtitle">各时间段文章发布数量变化</div>
</div>
<div ref="publishDatesChart" v-loading="publishDatesChartLoading" class="chart-body"></div>
</div>
<!-- 年度高频词汇 -->
<div class="chart-card chart-card-full">
<div class="chart-header">
<div class="chart-title">
<el-icon><Histogram /></el-icon>
<span>年度高频词汇分析</span>
</div>
<div class="chart-subtitle">基于文章分词结果的年度关键词统计</div>
</div>
<div ref="timelineWordsChart" v-loading="timelineWordsChartLoading" class="chart-body timeline-chart-body"></div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import http from '@/utils/http' import http from '@/utils/http'
import { Document, FolderOpened, Calendar, TrendCharts, PieChart, Histogram } from '@element-plus/icons-vue'
const categoriesChart = ref<HTMLElement>() const categoriesChart = ref<HTMLElement>()
const publishDatesChart = ref<HTMLElement>() const publishDatesChart = ref<HTMLElement>()
@ -20,70 +95,115 @@ const categoriesChartLoading = ref(false)
const publishDatesChartLoading = ref(false) const publishDatesChartLoading = ref(false)
const timelineWordsChartLoading = ref(false) const timelineWordsChartLoading = ref(false)
//
const totalArticles = ref(0)
const totalCategories = ref(0)
const earliestDate = ref('-')
const topKeyword = ref('-')
const categoriesChartOption: any = { const categoriesChartOption: any = {
title: {
text: '文章分类',
x: 'center',
top: 10
},
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: "{a} <br/>{b} : {c} ({d}%)" formatter: "{b}: {c}篇 ({d}%)"
}, },
legend: { legend: {
type: 'scroll', type: 'scroll',
orient: 'vertical', orient: 'vertical',
right: 10, right: 10,
top: 50, top: 40,
bottom: 20, bottom: 20,
data: [], textStyle: {
color: '#606266'
}
}, },
series: { series: {
name: '类别', name: '类别',
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['45%', '75%'],
center: ['40%', '50%'], center: ['35%', '50%'],
avoidLabelOverlap: false,
itemStyle: { itemStyle: {
borderRadius: 5, borderRadius: 8,
borderColor: '#FFF', borderColor: '#fff',
borderWidth: 1 borderWidth: 2
},
label: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
labelLine: {
show: false
}, },
data: [] data: []
} }
} }
const publishDatesChartOption: any = { const publishDatesChartOption: any = {
title: {
left: 'center',
text: '文章发布时间',
top: 10
},
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: {
color: '#606266'
},
axisPointer: { axisPointer: {
type: 'cross', type: 'line',
animation: false, lineStyle: {
label: { color: '#409eff',
backgroundColor: '#ccc', width: 2
borderColor: '#aaa',
borderWidth: 1,
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
color: '#222'
} }
}, },
}, },
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: { xAxis: {
name: '发布时间', name: '发布时间',
nameTextStyle: {
color: '#909399'
},
type: 'category', type: 'category',
boundaryGap: false, boundaryGap: false,
data: [] data: [],
axisLine: {
lineStyle: {
color: '#dcdfe6'
}
},
axisLabel: {
color: '#606266'
}
}, },
yAxis: { yAxis: {
name: '文章数量', name: '文章数量',
nameTextStyle: {
color: '#909399'
},
type: 'value', type: 'value',
max: function (value: {max: number}) { axisLine: {
return value.max + 10 show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#606266'
},
splitLine: {
lineStyle: {
color: '#ebeef5'
}
} }
}, },
dataZoom: [{ dataZoom: [{
@ -92,69 +212,156 @@ const publishDatesChartOption: any = {
end: 100 end: 100
}, { }, {
start: 0, start: 0,
end: 10 end: 100,
height: 20,
bottom: 10,
handleSize: '100%',
handleStyle: {
color: '#409eff'
}
}], }],
series: { series: {
name: '文章数量', name: '文章数量',
type: 'line', type: 'line',
smooth: true, smooth: true,
symbol: 'none', symbol: 'circle',
symbolSize: 8,
sampling: 'average', sampling: 'average',
itemStyle: { itemStyle: {
color: 'rgb(255, 70, 131)' color: '#409eff'
},
lineStyle: {
width: 3,
shadowColor: 'rgba(64, 158, 255, 0.3)',
shadowBlur: 10,
shadowOffsetY: 5
}, },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: 'rgb(255, 158, 68)' color: 'rgba(64, 158, 255, 0.4)'
}, { }, {
offset: 1, offset: 1,
color: 'rgb(255, 70, 131)' color: 'rgba(64, 158, 255, 0.05)'
} }
]) ])
}, },
data: [] data: []
} }
} }
const timelineWordsChartOption: any = { const timelineWordsChartOption: any = {
options: [], baseOption: {
timeline: { timeline: {
axisType: 'category', axisType: 'category',
autoPlay: false, autoPlay: false,
playInterval: 1000, playInterval: 1500,
data: [], data: [],
}, bottom: 10,
title: { left: 50,
left: 'center', right: 50,
subtext: '数据来自文章分词结果' lineStyle: {
}, color: '#dcdfe6'
calculable: true, },
grid: { itemStyle: {
top: 80, color: '#c0c4cc'
bottom: 80 },
}, checkpointStyle: {
xAxis: { color: '#409eff',
name: '高频词汇', borderColor: '#409eff'
type: 'category', },
splitLine: {show: false} controlStyle: {
}, itemSize: 20,
yAxis: { color: '#409eff',
name: '词汇出现次数', borderColor: '#409eff'
type: 'value' },
}, label: {
tooltip: { color: '#606266'
trigger: 'axis', }
axisPointer: { },
type: 'shadow' title: {
left: 'center',
top: 10,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#303133'
}
},
calculable: true,
grid: {
top: 60,
bottom: 80,
left: 60,
right: 40
},
xAxis: {
name: '高频词汇',
nameTextStyle: {
color: '#909399'
},
type: 'category',
axisLine: {
lineStyle: {
color: '#dcdfe6'
}
},
axisLabel: {
color: '#606266',
interval: 0,
rotate: 30
}
},
yAxis: {
name: '出现次数',
nameTextStyle: {
color: '#909399'
},
type: 'value',
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#606266'
},
splitLine: {
lineStyle: {
color: '#ebeef5'
}
}
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e4e7ed',
borderWidth: 1,
textStyle: {
color: '#606266'
},
axisPointer: {
type: 'shadow',
shadowStyle: {
color: 'rgba(64, 158, 255, 0.1)'
}
}
},
series: {
type: 'bar',
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#409eff' },
{ offset: 1, color: '#67c23a' }
])
},
barWidth: '50%'
} }
}, },
series: { options: []
type: 'bar',
itemStyle: {
borderRadius: 5
}
}
} }
onMounted(async () => { onMounted(async () => {
@ -162,49 +369,196 @@ onMounted(async () => {
publishDatesChartLoading.value = true publishDatesChartLoading.value = true
timelineWordsChartLoading.value = true timelineWordsChartLoading.value = true
//
const articleData = await http.get<{params:{type:string}}, any>('/api/v1/article/statistics', {params: {type: 'normal'}}) const articleData = await http.get<{params:{type:string}}, any>('/api/v1/article/statistics', {params: {type: 'normal'}})
categoriesChartOption.legend.data = articleData.categories.map((item: any) => item._id)
categoriesChartOption.series.data = articleData.categories.map((item: any) => {
return {name: item._id, value: item.cnt}
})
publishDatesChartOption.xAxis.data = articleData.publishDates.map((item: any) => item._id)
publishDatesChartOption.series.data = articleData.publishDates.map((item: any) => item.cnt)
//
totalArticles.value = articleData.categories.reduce((sum: number, item: any) => sum + item.cnt, 0)
totalCategories.value = articleData.categories.length
if (articleData.publishDates.length > 0) {
earliestDate.value = articleData.publishDates[0]._id
}
//
categoriesChartOption.series.data = articleData.categories.map((item: any) => ({
name: item._id,
value: item.cnt
}))
const categoriesChartInstance = echarts.init(categoriesChart.value as HTMLElement) const categoriesChartInstance = echarts.init(categoriesChart.value as HTMLElement)
categoriesChartInstance.setOption(categoriesChartOption) categoriesChartInstance.setOption(categoriesChartOption)
categoriesChartLoading.value = false
//
publishDatesChartOption.xAxis.data = articleData.publishDates.map((item: any) => item._id)
publishDatesChartOption.series.data = articleData.publishDates.map((item: any) => item.cnt)
const publishDatesChartInstance = echarts.init(publishDatesChart.value as HTMLElement) const publishDatesChartInstance = echarts.init(publishDatesChart.value as HTMLElement)
publishDatesChartInstance.setOption(publishDatesChartOption) publishDatesChartInstance.setOption(publishDatesChartOption)
categoriesChartLoading.value = false
publishDatesChartLoading.value = false publishDatesChartLoading.value = false
//
const timelineData = await http.get<{params:{type:string}}, any>('/api/v1/article/statistics', {params: {type: 'timelineWords'}}) const timelineData = await http.get<{params:{type:string}}, any>('/api/v1/article/statistics', {params: {type: 'timelineWords'}})
timelineWordsChartOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
timelineData.timelineWords.forEach((item: any) => { if (timelineData.timelineWords.length > 0 && timelineData.timelineWords[0].keys.length > 0) {
timelineWordsChartOption.options.push({ topKeyword.value = timelineData.timelineWords[0].keys[0].key
title: {text: `${item._id}年发布的文章`}, }
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
series: {data: item.keys.map((keyItem: any) => keyItem.total)} timelineWordsChartOption.baseOption.timeline.data = timelineData.timelineWords.map((item: any) => item._id)
}) timelineWordsChartOption.options = timelineData.timelineWords.map((item: any) => ({
}) title: {text: `${item._id}年发布的文章 - 高频词汇TOP10`},
xAxis: {data: item.keys.map((keyItem: any) => keyItem.key)},
series: {data: item.keys.map((keyItem: any) => keyItem.total)}
}))
const timelineWordsChartInstance = echarts.init(timelineWordsChart.value as HTMLElement) const timelineWordsChartInstance = echarts.init(timelineWordsChart.value as HTMLElement)
timelineWordsChartInstance.setOption(timelineWordsChartOption) timelineWordsChartInstance.setOption(timelineWordsChartOption)
timelineWordsChartLoading.value = false timelineWordsChartLoading.value = false
//
window.addEventListener('resize', () => {
categoriesChartInstance.resize()
publishDatesChartInstance.resize()
timelineWordsChartInstance.resize()
})
}) })
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.echarts-container { .statistics-page {
display: grid; padding: 20px;
grid-template-columns: 50% 50%;
grid-template-rows: 50% 50%;
height: 100%; height: 100%;
> .echarts { overflow: auto;
border: 1px solid #ccc; box-sizing: border-box;
width: auto; }
height: auto;
//
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: var(--el-bg-color);
border-radius: var(--el-border-radius-large);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
transition: transform 0.3s, box-shadow 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
} }
} }
.timeline-chart {
grid-column-start: 1; .stat-icon {
grid-column-end: 3; width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
flex-shrink: 0;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
//
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: auto auto;
gap: 20px;
}
.chart-card {
background: var(--el-bg-color);
border-radius: var(--el-border-radius-large);
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
display: flex;
flex-direction: column;
&-full {
grid-column: span 2;
}
}
.chart-header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.chart-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
.el-icon {
color: var(--el-color-primary);
font-size: 18px;
}
}
.chart-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
padding-left: 26px;
}
.chart-body {
flex: 1;
min-height: 320px;
}
.timeline-chart-body {
min-height: 420px;
}
//
@media (max-width: 1200px) {
.stat-cards {
grid-template-columns: repeat(2, 1fr);
}
.charts-grid {
grid-template-columns: 1fr;
}
.chart-card-full {
grid-column: span 1;
}
}
@media (max-width: 768px) {
.stat-cards {
grid-template-columns: 1fr;
}
} }
</style> </style>