overview统计

This commit is contained in:
2025-11-14 18:31:39 +08:00
parent 6be3cc6abd
commit 9adc0c2058
24 changed files with 723 additions and 178 deletions

View File

@@ -10,15 +10,16 @@ export interface SystemStatisticsDTO {
totalUsersChange: string;
totalResources: number;
totalResourcesChange: string;
todayVisits: number;
todayVisitsChange: string;
activeUsers: number;
activeUsersChange: string;
totalPv: number;
totalPvChange: string;
totalUv: number;
totalUvChange: string;
}
export interface ActiveUsersChartDTO {
labels: string[];
values: number[];
pvValues: number[];
uvValues: number[];
}
export interface ResourceCategoryItemDTO {
@@ -56,5 +57,16 @@ export const systemOverviewApi = {
async getTodayVisits(): Promise<ResultDomain<TodayVisitsDTO>> {
const response = await api.get<TodayVisitsDTO>('/system/overview/today-visits');
return response.data;
},
/**
* 记录一次页面访问PV前端在路由切换时调用
*/
async trackVisit(): Promise<ResultDomain<string>> {
const response = await api.get<string>('/system/overview/track-visit', undefined, {
showLoading: false,
showError: false
});
return response.data;
}
};

View File

@@ -0,0 +1,5 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#FDF2F8"/>
<path d="M24.0003 15C29.3924 15 33.8784 18.8798 34.8189 24C33.8784 29.1202 29.3924 33 24.0003 33C18.6081 33 14.1222 29.1202 13.1816 24C14.1222 18.8798 18.6081 15 24.0003 15ZM24.0003 31C28.2359 31 31.8603 28.052 32.7777 24C31.8603 19.948 28.2359 17 24.0003 17C19.7646 17 16.1402 19.948 15.2228 24C16.1402 28.052 19.7646 31 24.0003 31ZM24.0003 28.5C21.515 28.5 19.5003 26.4853 19.5003 24C19.5003 21.5147 21.515 19.5 24.0003 19.5C26.4855 19.5 28.5003 21.5147 28.5003 24C28.5003 26.4853 26.4855 28.5 24.0003 28.5ZM24.0003 26.5C25.381 26.5 26.5003 25.3807 26.5003 24C26.5003 22.6193 25.381 21.5 24.0003 21.5C22.6196 21.5 21.5003 22.6193 21.5003 24C21.5003 25.3807 22.6196 26.5 24.0003 26.5Z" fill="#ED4F9D"/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@@ -0,0 +1,5 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#EFF6FF"/>
<path d="M33 20V32.9932C33 33.5501 32.5552 34 32.0066 34H15.9934C15.445 34 15 33.556 15 33.0082V14.9918C15 14.4553 15.4487 14 16.0022 14H26.9968L33 20ZM31 21H26V16H17V32H31V21ZM20 19H23V21H20V19ZM20 23H28V25H20V23ZM20 27H28V29H20V27Z" fill="#2563EB"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@@ -0,0 +1,5 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#FFFBEB"/>
<path d="M32 34H30V32C30 30.3431 28.6569 29 27 29H21C19.3432 29 18 30.3431 18 32V34H16V32C16 29.2386 18.2386 27 21 27H27C29.7614 27 32 29.2386 32 32V34ZM24 25C20.6863 25 18 22.3137 18 19C18 15.6863 20.6863 13 24 13C27.3137 13 30 15.6863 30 19C30 22.3137 27.3137 25 24 25ZM24 23C26.2091 23 28 21.2091 28 19C28 16.7909 26.2091 15 24 15C21.7909 15 20 16.7909 20 19C20 21.2091 21.7909 23 24 23Z" fill="#F6A723"/>
</svg>

After

Width:  |  Height:  |  Size: 574 B

View File

@@ -0,0 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#FFF2EB"/>
<path d="M28 19H34V25" stroke="#F54900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M34 19L25.5 27.5L20.5 22.5L14 29" stroke="#F54900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B

View File

@@ -7,6 +7,7 @@
import type { Router, NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
import type { Store } from 'vuex';
import { AuthState } from '@/store/modules/auth';
import { systemOverviewApi } from '@/apis/system/overview';
/**
* 白名单路由 - 无需登录即可访问
@@ -42,13 +43,19 @@ export function setupRouterGuards(router: Router, store: Store<any>) {
});
// 全局后置钩子
router.afterEach((to) => {
// 结束页面加载进度条
finishProgress();
// 设置页面标题
setPageTitle(to.meta?.title as string);
// 统计PV对path以"/"开头的路由进行统计
if (to.path.startsWith('/')) {
systemOverviewApi.trackVisit().catch(() => {
// 统计失败不影响正常页面使用
});
}
});
// 全局解析守卫(在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用)

View File

@@ -4,15 +4,55 @@
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card" v-for="stat in statistics" :key="stat.label">
<div class="stat-icon" :style="{ background: stat.color }">
<i>{{ stat.icon }}</i>
</div>
<!-- 总用户 -->
<div class="stat-card">
<div class="stat-icon"><img src="@/assets/imgs/overview-user.svg" alt="用户" /></div>
<div class="stat-content">
<h3>{{ stat.value }}</h3>
<p>{{ stat.label }}</p>
<span class="stat-change" :class="stat.trend">
{{ stat.change }}
<h3>用户总数</h3>
<span>
{{ statistics.totalUsers }}
<span>
{{ statistics.totalUsersChange }} 较昨日
</span>
</span>
</div>
</div>
<!-- 总资源 -->
<div class="stat-card">
<div class="stat-icon"><img src="@/assets/imgs/overview-resource.svg" alt="资源" /></div>
<div class="stat-content">
<h3>资源总数</h3>
<span>
{{ statistics.totalResources }}
<span>
{{ statistics.totalResourcesChange }} 较昨日
</span>
</span>
</div>
</div>
<!-- 今日pv -->
<div class="stat-card">
<div class="stat-icon"><img src="@/assets/imgs/overview-pv.svg" alt="pv" /></div>
<div class="stat-content">
<h3>今日访问</h3>
<span>
{{ statistics.totalPv }}
<span>
{{ statistics.totalPvChange }} 较昨日
</span>
</span>
</div>
</div>
<!-- 今日uv -->
<div class="stat-card">
<div class="stat-icon"><img src="@/assets/imgs/overview-uv.svg" alt="uv" /></div>
<div class="stat-content">
<h3>今日用户</h3>
<span>
{{ statistics.totalUv }}
<span>
{{ statistics.totalUvChange }} 较昨日
</span>
</span>
</div>
</div>
@@ -24,7 +64,7 @@
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>用户活跃度折线图</span>
<span>访问统计趋势PV/UV</span>
<el-date-picker
v-model="dateRange"
type="daterange"
@@ -49,26 +89,15 @@
</el-col>
</el-row>
<!-- 今日访问量详情 -->
<el-card class="visit-card">
<template #header>
<span>今日访问量</span>
</template>
<div class="visit-stats">
<div class="visit-item" v-for="item in visitStats" :key="item.label">
<div class="visit-label">{{ item.label }}</div>
<div class="visit-value">{{ item.value }}</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { ElRow, ElCol, ElCard, ElDatePicker } from 'element-plus';
import * as echarts from 'echarts';
import { systemOverviewApi } from '@/apis/system/overview';
import dayjs from 'dayjs';
const dateRange = ref<[Date, Date] | null>(null);
const activityChart = ref<HTMLElement | null>(null);
@@ -76,18 +105,23 @@ const resourcePieChart = ref<HTMLElement | null>(null);
let activityChartInstance: echarts.ECharts | null = null;
let pieChartInstance: echarts.ECharts | null = null;
const statistics = ref<{
icon: string;
label: string;
value: string | number;
change: string;
trend: 'up' | 'down';
color: string;
}[]>([]);
const visitStats = ref<{ label: string; value: string | number }[]>([]);
const statistics = ref({
totalUsers: 0,
totalUsersChange: '+0%',
totalResources: 0,
totalResourcesChange: '+0%',
totalPv: 0,
totalPvChange: '+0%',
totalUv: 0,
totalUvChange: '+0%'
});
onMounted(async () => {
// 默认选择最近 7 天
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
dateRange.value = [sevenDaysAgo, now];
initCharts();
await loadOverviewData();
});
@@ -101,65 +135,55 @@ onUnmounted(() => {
}
});
// 监听日期范围变化,重新加载活跃用户图表
watch(dateRange, async (newRange) => {
if (newRange && newRange.length === 2) {
try {
const activeRes = await systemOverviewApi.getActiveUsersChart(
formatDate(newRange[0]),
formatDate(newRange[1])
);
if (activeRes.success && activeRes.data) {
updateActivityChart(activeRes.data.labels, activeRes.data.pvValues, activeRes.data.uvValues);
}
} catch (error) {
console.error('加载活跃用户图表数据失败:', error);
}
}
});
function formatDate(date: Date) {
return dayjs(date).format('YYYY-MM-DD');
}
async function loadOverviewData() {
try {
const [statRes, activeRes, pieRes, todayRes] = await Promise.all([
systemOverviewApi.getStatistics(),
systemOverviewApi.getActiveUsersChart('2025-10-15', '2025-10-21'),
systemOverviewApi.getActiveUsersChart(formatDate(dateRange.value?.[0]!), formatDate(dateRange.value?.[1]!)),
systemOverviewApi.getResourceCategoryStats(),
systemOverviewApi.getTodayVisits()
]);
if (statRes.success && statRes.data) {
const d = statRes.data;
statistics.value = [
{
icon: '👥',
label: '总用户数',
value: d.totalUsers,
change: d.totalUsersChange,
trend: d.totalUsersChange.startsWith('-') ? 'down' : 'up',
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
},
{
icon: '📚',
label: '总资源数',
value: d.totalResources,
change: d.totalResourcesChange,
trend: d.totalResourcesChange.startsWith('-') ? 'down' : 'up',
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'
},
{
icon: '👁',
label: '今日访问量',
value: d.todayVisits,
change: d.todayVisitsChange,
trend: d.todayVisitsChange.startsWith('-') ? 'down' : 'up',
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'
},
{
icon: '✅',
label: '活跃用户',
value: d.activeUsers,
change: d.activeUsersChange,
trend: d.activeUsersChange.startsWith('-') ? 'down' : 'up',
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)'
}
];
statistics.value = {
totalUsers: d.totalUsers ?? 0,
totalUsersChange: String(d.totalUsersChange ?? '+0%'),
totalResources: d.totalResources ?? 0,
totalResourcesChange: String(d.totalResourcesChange ?? '+0%'),
totalPv: d.totalPv ?? 0,
totalPvChange: String(d.totalPvChange ?? '+0%'),
totalUv: d.totalUv ?? 0,
totalUvChange: String(d.totalUvChange ?? '+0%')
};
}
if (todayRes.success && todayRes.data) {
const t = todayRes.data;
visitStats.value = [
{ label: 'UV(独立访客)', value: t.uv },
{ label: 'PV(页面浏览量)', value: t.pv },
{ label: '平均访问时长', value: t.avgVisitDuration },
{ label: '跳出率', value: t.bounceRate }
];
// todayRes 仍用于“今日访问”区域(如后续需要),当前统计卡片只使用 statistics
}
if (activeRes.success && activeRes.data) {
updateActivityChart(activeRes.data.labels, activeRes.data.values);
updateActivityChart(activeRes.data.labels, activeRes.data.pvValues, activeRes.data.uvValues);
}
if (pieRes.success && pieRes.data) {
@@ -180,14 +204,28 @@ function initCharts() {
}
}
function updateActivityChart(labels: string[], values: number[]) {
function updateActivityChart(labels: string[], pvValues: number[], uvValues: number[]) {
if (!activityChartInstance) return;
const option = {
tooltip: {
trigger: 'axis'
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['页面访问量 (PV)', '独立访客数 (UV)'],
top: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: labels
},
yAxis: {
@@ -195,10 +233,48 @@ function updateActivityChart(labels: string[], values: number[]) {
},
series: [
{
data: values,
name: '页面访问量 (PV)',
data: pvValues,
type: 'line',
smooth: true,
areaStyle: {}
itemStyle: {
color: '#5470c6'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(84, 112, 198, 0.3)' },
{ offset: 1, color: 'rgba(84, 112, 198, 0.05)' }
]
}
}
},
{
name: '独立访客数 (UV)',
data: uvValues,
type: 'line',
smooth: true,
itemStyle: {
color: '#91cc75'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(145, 204, 117, 0.3)' },
{ offset: 1, color: 'rgba(145, 204, 117, 0.05)' }
]
}
}
}
]
};
@@ -229,6 +305,7 @@ function updatePieChart(items: { name: string; value: number }[]) {
<style lang="scss" scoped>
.system-overview {
padding: 16px 0;
}
.page-title {
@@ -241,56 +318,59 @@ function updatePieChart(items: { name: string; value: number }[]) {
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 20px;
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
background: #ffffff;
padding: 20px 24px;
border-radius: 16px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 12px;
width: 48px;
height: 48px;
border-radius: 20%;
background: #f5f7ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
flex-shrink: 0;
img {
width: 24px;
height: 24px;
}
}
.stat-content {
flex: 1;
h3 {
font-size: 14px;
font-weight: 500;
color: #64748b;
margin-bottom: 6px;
}
> span {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 28px;
font-weight: 600;
color: #141F38;
margin-bottom: 4px;
}
p {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
}
color: #0f172a;
.stat-change {
font-size: 13px;
&.up {
color: #4caf50;
}
&.down {
color: #f44336;
> span {
font-size: 12px;
font-weight: 400;
color: #16a34a; // 默认上升为绿色
}
}
}