修复内容统计
This commit is contained in:
parent
da82e82e7c
commit
2420ce7660
32
package-lock.json
generated
32
package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"chart": "^0.1.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"docx-preview": "^0.3.7",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.11.7",
|
||||
"less": "^4.4.2",
|
||||
"marked": "^16.4.1",
|
||||
@ -2269,6 +2270,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.11.7",
|
||||
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.11.7.tgz",
|
||||
@ -5642,6 +5659,21 @@
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"chart": "^0.1.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"docx-preview": "^0.3.7",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.11.7",
|
||||
"less": "^4.4.2",
|
||||
"marked": "^16.4.1",
|
||||
|
||||
18
src/api/analytics.js
Normal file
18
src/api/analytics.js
Normal file
@ -0,0 +1,18 @@
|
||||
// 数据统计相关API
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 获取内容统计
|
||||
export function getContentStats() {
|
||||
return request({
|
||||
url: "/admin/contentstats",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
// 获取用户统计
|
||||
export function getUserStats() {
|
||||
return request({
|
||||
url: "/admin/usersstats",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
@ -27,7 +27,7 @@
|
||||
:default-active="route.path"
|
||||
>
|
||||
<!-- 菜单标题 -->
|
||||
<h3>{{ isCollapse ? '管理' : '管理后台' }}</h3>
|
||||
<h3>{{ isCollapse ? '管理' : '云泽管理后台' }}</h3>
|
||||
|
||||
<!-- 固定仪表盘 -->
|
||||
<el-menu-item index="/dashboard">
|
||||
|
||||
@ -2,12 +2,17 @@ import { createComponentLoader } from '@/utils/pathResolver';
|
||||
import { h, resolveComponent as resolveVueComponent } from 'vue';
|
||||
|
||||
// 递归转换嵌套菜单为嵌套路由
|
||||
export function convertMenusToRoutes(menus) {
|
||||
export function convertMenusToRoutes(menus, parentPath = '') {
|
||||
if (!menus || menus.length === 0) return [];
|
||||
|
||||
return menus.map(menu => {
|
||||
// 拼接完整的路由路径(处理相对路径)
|
||||
const fullPath = menu.path ?
|
||||
(menu.path.startsWith('/') ? menu.path : `${parentPath}/${menu.path}`)
|
||||
: '';
|
||||
|
||||
const route = {
|
||||
path: menu.path || '',
|
||||
path: fullPath || menu.path || '',
|
||||
name: `menu_${menu.id}`,
|
||||
meta: {
|
||||
title: menu.title,
|
||||
@ -32,32 +37,21 @@ export function convertMenusToRoutes(menus) {
|
||||
route.component = () => import('@/views/404/404.vue');
|
||||
}
|
||||
|
||||
// 2. 递归子路由
|
||||
// 2. 递归子路由(传递当前完整路径作为父路径)
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = convertMenusToRoutes(menu.children);
|
||||
route.children = convertMenusToRoutes(menu.children, fullPath);
|
||||
|
||||
// 目录节点添加重定向,防止点击父菜单页面空白
|
||||
const firstChild = menu.children[0];
|
||||
if (firstChild && firstChild.path) {
|
||||
route.redirect = firstChild.path;
|
||||
// 计算第一个子路由的完整路径
|
||||
const childFullPath = firstChild.path.startsWith('/') ?
|
||||
firstChild.path :
|
||||
`${fullPath}/${firstChild.path}`;
|
||||
route.redirect = childFullPath;
|
||||
}
|
||||
}
|
||||
|
||||
return route;
|
||||
});
|
||||
}
|
||||
|
||||
// 查找第一个有效的子路由
|
||||
function findFirstValidRoute(children) {
|
||||
if (!children || children.length === 0) return null;
|
||||
for (const child of children) {
|
||||
if (child.path && (child.component_path || (child.children && child.children.length > 0))) {
|
||||
return child;
|
||||
}
|
||||
if (child.children && child.children.length > 0) {
|
||||
const grandChild = findFirstValidRoute(child.children);
|
||||
if (grandChild) return grandChild;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
245
src/views/analytics/content/index.vue
Normal file
245
src/views/analytics/content/index.vue
Normal file
@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="content-stats-container">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6" v-for="card in topCards" :key="card.label">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="label">{{ card.label }}</div>
|
||||
<div class="count">{{ card.count.toLocaleString() }}</div>
|
||||
<div class="sub-info">
|
||||
昨日
|
||||
<span :class="card.trend >= 0 ? 'plus' : 'minus'">
|
||||
{{ card.trend >= 0 ? "+" : "" }}{{ card.trend }}
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="main-charts">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>内容发布活跃度</span>
|
||||
<el-radio-group v-model="timeRange" size="small">
|
||||
<el-radio-button label="week">近一周</el-radio-button>
|
||||
<el-radio-button label="month">近一月</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="barChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" header="内容分类占比">
|
||||
<div ref="categoryChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="bottom-row">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="hover" header="热门内容TOP 5">
|
||||
<el-table :data="hotContent" style="width: 100%" stripe>
|
||||
<el-table-column type="index" label="排名" width="80" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="cate" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.cate || '未分类' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="views" label="阅读量" sortable />
|
||||
<el-table-column prop="likes" label="点赞数" width="120" />
|
||||
<el-table-column prop="publish_date" label="发布时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.publish_date ? row.publish_date.slice(0, 16).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, shallowRef } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
import { getContentStats } from "@/api/analytics";
|
||||
|
||||
interface TopCard {
|
||||
label: string;
|
||||
count: number;
|
||||
trend: number;
|
||||
}
|
||||
|
||||
interface HotArticle {
|
||||
id: number;
|
||||
title: string;
|
||||
cate: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
publish_date: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const timeRange = ref("week");
|
||||
const barChartRef = ref<HTMLElement | null>(null);
|
||||
const categoryChartRef = ref<HTMLElement | null>(null);
|
||||
const barChart = shallowRef<echarts.ECharts | null>(null);
|
||||
const categoryChart = shallowRef<echarts.ECharts | null>(null);
|
||||
|
||||
const topCards = ref<TopCard[]>([
|
||||
{ label: "总发布量", count: 0, trend: 0 },
|
||||
{ label: "本月新增", count: 0, trend: 0 },
|
||||
{ label: "总点赞量", count: 0, trend: 0 },
|
||||
{ label: "总访问量", count: 0, trend: 0 },
|
||||
]);
|
||||
|
||||
const hotContent = ref<HotArticle[]>([]);
|
||||
|
||||
async function fetchContentStats() {
|
||||
const res = await getContentStats();
|
||||
if (res.code === 200 && res.data) {
|
||||
const { overview, hot_articles } = res.data;
|
||||
|
||||
topCards.value = [
|
||||
{ label: "总发布量", count: overview.total_articles.value, trend: overview.total_articles.growth },
|
||||
{ label: "本月新增", count: overview.month_new.value, trend: overview.month_new.growth },
|
||||
{ label: "总点赞量", count: overview.total_likes.value, trend: overview.total_likes.growth },
|
||||
{ label: "总访问量", count: overview.total_views.value, trend: overview.total_views.growth },
|
||||
];
|
||||
|
||||
hotContent.value = hot_articles || [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- 图表初始化 ---
|
||||
const initCharts = () => {
|
||||
// 柱状图:发布趋势
|
||||
if (barChartRef.value) {
|
||||
barChart.value = echarts.init(barChartRef.value);
|
||||
barChart.value.setOption({
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
axisLine: { lineStyle: { color: "#DCDFE6" } },
|
||||
},
|
||||
yAxis: { type: "value" },
|
||||
series: [
|
||||
{
|
||||
name: "发布篇数",
|
||||
data: [45, 52, 38, 65, 48, 23, 31],
|
||||
type: "bar",
|
||||
barWidth: "40%",
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "#83bff6" },
|
||||
{ offset: 0.5, color: "#188df0" },
|
||||
{ offset: 1, color: "#188df0" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 环形图:分类占比
|
||||
if (categoryChartRef.value) {
|
||||
categoryChart.value = echarts.init(categoryChartRef.value);
|
||||
categoryChart.value.setOption({
|
||||
tooltip: { trigger: "item" },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["50%", "70%"],
|
||||
data: [
|
||||
{ value: 40, name: "技术文章" },
|
||||
{ value: 30, name: "行业资讯" },
|
||||
{ value: 20, name: "视频教程" },
|
||||
{ value: 10, name: "资源分享" },
|
||||
],
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: "16", fontWeight: "bold" },
|
||||
},
|
||||
label: { show: false, position: "center" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
barChart.value?.resize();
|
||||
categoryChart.value?.resize();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchContentStats();
|
||||
initCharts();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-stats-container {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
.label {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub-info {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
.plus {
|
||||
color: #52c41a;
|
||||
}
|
||||
.minus {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-charts {
|
||||
margin-bottom: 24px;
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chart-box {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
196
src/views/analytics/users/index.vue
Normal file
196
src/views/analytics/users/index.vue
Normal file
@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="statistics-container">
|
||||
<el-row :gutter="20" class="data-overview">
|
||||
<el-col :span="6" v-for="item in summaryData" :key="item.title">
|
||||
<el-card shadow="hover" class="data-card">
|
||||
<div class="card-content">
|
||||
<div class="icon-box" :style="{ backgroundColor: item.color }">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="text-box">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="value">{{ item.value.toLocaleString() }}</div>
|
||||
<div class="trend" :class="item.isUp ? 'up' : 'down'">
|
||||
{{ item.isUp ? '↑' : '↓' }} {{ item.percentage }}%
|
||||
<span>较上月</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="charts-row">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" header="用户增长趋势">
|
||||
<div ref="lineChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" header="用户等级分布">
|
||||
<div ref="pieChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { User, Pointer, Connection, Histogram } from '@element-plus/icons-vue';
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface SummaryItem {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: any;
|
||||
color: string;
|
||||
percentage: number;
|
||||
isUp: boolean;
|
||||
}
|
||||
|
||||
// --- 响应式数据 ---
|
||||
const lineChartRef = ref<HTMLElement | null>(null);
|
||||
const pieChartRef = ref<HTMLElement | null>(null);
|
||||
const lineChartInstance = shallowRef<echarts.ECharts | null>(null);
|
||||
const pieChartInstance = shallowRef<echarts.ECharts | null>(null);
|
||||
|
||||
const summaryData = ref<SummaryItem[]>([
|
||||
{ title: '总用户数', value: 12840, icon: User, color: '#409EFF', percentage: 12, isUp: true },
|
||||
{ title: '今日新增', value: 156, icon: Pointer, color: '#67C23A', percentage: 5, isUp: true },
|
||||
{ title: '活跃用户', value: 3420, icon: Connection, color: '#E6A23C', percentage: 2, isUp: false },
|
||||
{ title: '留存率', value: 85, icon: Histogram, color: '#F56C6C', percentage: 1, isUp: true },
|
||||
]);
|
||||
|
||||
// --- 初始化图表 ---
|
||||
const initCharts = () => {
|
||||
// 折线图配置
|
||||
if (lineChartRef.value) {
|
||||
lineChartInstance.value = echarts.init(lineChartRef.value);
|
||||
lineChartInstance.value.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{
|
||||
name: '新增用户',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [120, 132, 101, 134, 90, 230, 210],
|
||||
areaStyle: { opacity: 0.3 },
|
||||
itemStyle: { color: '#409EFF' }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 饼图配置
|
||||
if (pieChartRef.value) {
|
||||
pieChartInstance.value = echarts.init(pieChartRef.value);
|
||||
pieChartInstance.value.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: '0%', left: 'center' },
|
||||
series: [
|
||||
{
|
||||
name: '等级分布',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: [
|
||||
{ value: 1048, name: '普通用户' },
|
||||
{ value: 735, name: 'VIP会员' },
|
||||
{ value: 580, name: '超级管理员' },
|
||||
{ value: 484, name: '运营人员' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// --- 生命周期与自适应 ---
|
||||
const handleResize = () => {
|
||||
lineChartInstance.value?.resize();
|
||||
pieChartInstance.value?.resize();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initCharts();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.statistics-container {
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
|
||||
.data-overview {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.data-card {
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon-box {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.text-box {
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
.value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 4px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.trend {
|
||||
font-size: 12px;
|
||||
&.up { color: #67c23a; }
|
||||
&.down { color: #f56c6c; }
|
||||
span { color: #909399; margin-left: 4px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
.chart-box {
|
||||
height: 350px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深度修改 Element Plus 卡片头部样式
|
||||
:deep(.el-card__header) {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
</style>
|
||||
@ -116,7 +116,7 @@
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="publishdate" label="发布时间" width="160" />
|
||||
<el-table-column prop="publish_date" label="发布时间" width="160" />
|
||||
<el-table-column prop="update_time" label="更新时间" width="160" />
|
||||
<el-table-column prop="publisher" label="发布人" width="120" />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user