Files
vue3-element-admin/src/views/dashboard/index.vue

662 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="dashboard-container">
<!-- github 角标 -->
<github-corner class="github-corner" />
<el-card shadow="never" class="mt-2">
<div class="flex flex-wrap">
<!-- 左侧问候语区域 -->
<div class="flex-1 flex items-start">
<img
class="w80px h80px rounded-full"
:src="userStore.userInfo.avatar + '?imageView2/1/w/80/h/80'"
/>
<div class="ml-5">
<p>{{ greetings }}</p>
<p class="text-sm text-gray">今日天气晴朗气温在15至25之间东南风</p>
</div>
</div>
<!-- 右侧图标区域 - PC端 -->
<div class="hidden sm:block">
<div class="flex items-end space-x-6">
<!-- 仓库 -->
<div>
<div class="font-bold color-#ff9a2e text-sm flex items-center">
<el-icon class="mr-2px"><Folder /></el-icon>
仓库
</div>
<div class="mt-3 whitespace-nowrap">
<el-link href="https://gitee.com/youlaiorg/vue3-element-admin" target="_blank">
<div class="i-svg:gitee text-lg color-#F76560" />
</el-link>
<el-divider direction="vertical" />
<el-link href="https://github.com/youlaitech/vue3-element-admin" target="_blank">
<div class="i-svg:github text-lg color-#4080FF" />
</el-link>
<el-divider direction="vertical" />
<el-link href="https://gitcode.com/youlai/vue3-element-admin" target="_blank">
<div class="i-svg:gitcode text-lg color-#FF9A2E" />
</el-link>
</div>
</div>
<!-- 文档 -->
<div>
<div class="font-bold color-#4080ff text-sm flex items-center">
<el-icon class="mr-2px"><Document /></el-icon>
文档
</div>
<div class="mt-3 whitespace-nowrap">
<el-link href="https://juejin.cn/post/7228990409909108793" target="_blank">
<div class="i-svg:juejin text-lg" />
</el-link>
<el-divider direction="vertical" />
<el-link
href="https://youlai.blog.csdn.net/article/details/130191394"
target="_blank"
>
<div class="i-svg:csdn text-lg" />
</el-link>
<el-divider direction="vertical" />
<el-link href="https://www.cnblogs.com/haoxianrui/p/17331952.html" target="_blank">
<div class="i-svg:cnblogs text-lg" />
</el-link>
</div>
</div>
<!-- 视频 -->
<div>
<div class="font-bold color-#f76560 text-sm flex items-center">
<el-icon class="mr-2px"><VideoCamera /></el-icon>
视频
</div>
<div class="mt-3 whitespace-nowrap">
<el-link href="https://www.bilibili.com/video/BV1eFUuYyEFj" target="_blank">
<div class="i-svg:bilibili text-lg" />
</el-link>
</div>
</div>
</div>
</div>
<!-- 移动端图标区域 -->
<div class="w-full sm:hidden mt-3">
<div class="flex justify-end space-x-4 overflow-x-auto">
<!-- 仓库图标 -->
<el-link href="https://gitee.com/youlaiorg/vue3-element-admin" target="_blank">
<div class="i-svg:gitee text-lg color-#F76560" />
</el-link>
<el-link href="https://github.com/youlaitech/vue3-element-admin" target="_blank">
<div class="i-svg:github text-lg color-#4080FF" />
</el-link>
<el-link href="https://gitcode.com/youlai/vue3-element-admin" target="_blank">
<div class="i-svg:gitcode text-lg color-#FF9A2E" />
</el-link>
<!-- 文档图标 -->
<el-link href="https://juejin.cn/post/7228990409909108793" target="_blank">
<div class="i-svg:juejin text-lg" />
</el-link>
<el-link href="https://youlai.blog.csdn.net/article/details/130191394" target="_blank">
<div class="i-svg:csdn text-lg" />
</el-link>
<el-link href="https://www.cnblogs.com/haoxianrui/p/17331952.html" target="_blank">
<div class="i-svg:cnblogs text-lg" />
</el-link>
<!-- 视频图标 -->
<el-link href="https://www.bilibili.com/video/BV1eFUuYyEFj" target="_blank">
<div class="i-svg:bilibili text-lg" />
</el-link>
</div>
</div>
</div>
</el-card>
<!-- 数据统计 -->
<el-row :gutter="10" class="mt-5">
<!-- 在线用户数量 -->
<el-col :span="8" :xs="24" class="mb-xs-3">
<el-card shadow="never" class="h-full flex flex-col">
<template #header>
<div class="flex-x-between">
<span class="text-gray">在线用户</span>
<el-tag type="danger" size="small">实时</el-tag>
</div>
</template>
<div class="flex-x-between mt-2 flex-1">
<div class="flex-y-center">
<span class="text-lg transition-all duration-300 hover:scale-110">
{{ onlineUserCount }}
</span>
<span v-if="isConnected" class="ml-2 text-xs text-[#67c23a]">
<el-icon><Connection /></el-icon>
已连接
</span>
<span v-else class="ml-2 text-xs text-[#f56c6c]">
<el-icon><Failed /></el-icon>
未连接
</span>
</div>
<div class="i-svg:people w-8 h-8 animate-[pulse_2s_infinite]" />
</div>
<div class="flex-x-between mt-2 text-sm text-gray">
<span>更新时间</span>
<span>{{ formattedTime }}</span>
</div>
</el-card>
</el-col>
<!-- 访客数(UV) -->
<el-col :span="8" :xs="24" class="mb-xs-3">
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
<template #template>
<el-card>
<template #header>
<div>
<el-skeleton-item variant="h3" style="width: 40%" />
<el-skeleton-item variant="rect" style="float: right; width: 1em; height: 1em" />
</div>
</template>
<div class="flex-x-between">
<el-skeleton-item variant="text" style="width: 30%" />
<el-skeleton-item variant="circle" style="width: 2em; height: 2em" />
</div>
<div class="mt-5 flex-x-between">
<el-skeleton-item variant="text" style="width: 50%" />
<el-skeleton-item variant="text" style="width: 1em" />
</div>
</el-card>
</template>
<template v-if="!visitStatsLoading">
<el-card shadow="never" class="h-full flex flex-col">
<template #header>
<div class="flex-x-between">
<span class="text-gray">访客数(UV)</span>
<el-tag type="success" size="small"></el-tag>
</div>
</template>
<div class="flex-x-between mt-2 flex-1">
<div class="flex-y-center">
<span class="text-lg">{{ Math.round(transitionUvCount) }}</span>
<span
:class="[
'text-xs',
'ml-2',
computeGrowthRateClass(visitStatsData.uvGrowthRate),
]"
>
<el-icon>
<Top v-if="visitStatsData.uvGrowthRate > 0" />
<Bottom v-else-if="visitStatsData.uvGrowthRate < 0" />
</el-icon>
{{ formatGrowthRate(visitStatsData.uvGrowthRate) }}
</span>
</div>
<div class="i-svg:visitor w-8 h-8" />
</div>
<div class="flex-x-between mt-2 text-sm text-gray">
<span>总访客数</span>
<span>{{ Math.round(transitionTotalUvCount) }}</span>
</div>
</el-card>
</template>
</el-skeleton>
</el-col>
<!-- 浏览量(PV) -->
<el-col :span="8" :xs="24">
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
<template #template>
<el-card>
<template #header>
<div>
<el-skeleton-item variant="h3" style="width: 40%" />
<el-skeleton-item variant="rect" style="float: right; width: 1em; height: 1em" />
</div>
</template>
<div class="flex-x-between">
<el-skeleton-item variant="text" style="width: 30%" />
<el-skeleton-item variant="circle" style="width: 2em; height: 2em" />
</div>
<div class="mt-5 flex-x-between">
<el-skeleton-item variant="text" style="width: 50%" />
<el-skeleton-item variant="text" style="width: 1em" />
</div>
</el-card>
</template>
<template v-if="!visitStatsLoading">
<el-card shadow="never" class="h-full flex flex-col">
<template #header>
<div class="flex-x-between">
<span class="text-gray">浏览量(PV)</span>
<el-tag type="primary" size="small"></el-tag>
</div>
</template>
<div class="flex-x-between mt-2 flex-1">
<div class="flex-y-center">
<span class="text-lg">{{ Math.round(transitionPvCount) }}</span>
<span
:class="[
'text-xs',
'ml-2',
computeGrowthRateClass(visitStatsData.pvGrowthRate),
]"
>
<el-icon>
<Top v-if="visitStatsData.pvGrowthRate > 0" />
<Bottom v-else-if="visitStatsData.pvGrowthRate < 0" />
</el-icon>
{{ formatGrowthRate(visitStatsData.pvGrowthRate) }}
</span>
</div>
<div class="i-svg:browser w-8 h-8" />
</div>
<div class="flex-x-between mt-2 text-sm text-gray">
<span>总浏览量</span>
<span>{{ Math.round(transitionTotalPvCount) }}</span>
</div>
</el-card>
</template>
</el-skeleton>
</el-col>
</el-row>
<el-row :gutter="10" class="mt-5">
<!-- 访问趋势统计图 -->
<el-col :xs="24" :span="16">
<el-card>
<template #header>
<div class="flex-x-between">
<span>访问趋势</span>
<el-radio-group v-model="visitTrendDateRange" size="small">
<el-radio-button :value="7">近7天</el-radio-button>
<el-radio-button :value="30">近30天</el-radio-button>
</el-radio-group>
</div>
</template>
<ECharts :options="visitTrendChartOptions" height="400px" />
</el-card>
</el-col>
<!-- 最新动态 -->
<el-col :xs="24" :span="8">
<el-card>
<template #header>
<div class="flex-x-between">
<span class="header-title">最新动态</span>
<el-link
type="primary"
underline="never"
href="https://gitee.com/youlaiorg/vue3-element-admin/releases"
target="_blank"
>
完整记录
<el-icon class="link-icon"><TopRight /></el-icon>
</el-link>
</div>
</template>
<el-scrollbar height="400px">
<el-timeline class="p-3">
<el-timeline-item
v-for="(item, index) in vesionList"
:key="index"
:timestamp="item.date"
placement="top"
:color="index === 0 ? '#67C23A' : '#909399'"
:hollow="index !== 0"
size="large"
>
<div class="version-item" :class="{ 'latest-item': index === 0 }">
<div>
<el-text tag="strong">{{ item.title }}</el-text>
<el-tag v-if="item.tag" :type="index === 0 ? 'success' : 'info'" size="small">
{{ item.tag }}
</el-tag>
</div>
<el-text class="version-content">{{ item.content }}</el-text>
<div v-if="item.link">
<el-link
:type="index === 0 ? 'primary' : 'info'"
:href="item.link"
target="_blank"
underline="never"
>
详情
<el-icon class="link-icon"><TopRight /></el-icon>
</el-link>
</div>
</div>
</el-timeline-item>
</el-timeline>
</el-scrollbar>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "Dashboard",
inheritAttrs: false,
});
import { dayjs } from "element-plus";
import LogAPI, { VisitStatsVO, VisitTrendVO } from "@/api/system/log.api";
import { useUserStore } from "@/store/modules/user.store";
import { formatGrowthRate } from "@/utils";
import { useTransition, useDateFormat } from "@vueuse/core";
import { Connection, Failed } from "@element-plus/icons-vue";
import { useOnlineCount } from "@/composables/useOnlineCount";
// 在线用户数量组件相关
const { onlineUserCount, lastUpdateTime, isConnected } = useOnlineCount();
// 记录上一次的用户数量用于计算趋势
const previousCount = ref(0);
// 监听用户数量变化,计算趋势
watch(onlineUserCount, (newCount, oldCount) => {
if (oldCount > 0) {
previousCount.value = oldCount;
}
});
// 格式化时间戳
const formattedTime = computed(() => {
if (!lastUpdateTime.value) return "--";
return useDateFormat(lastUpdateTime, "HH:mm:ss").value;
});
interface VersionItem {
id: string;
title: string; // 版本标题v2.4.0
date: string; // 发布时间
content: string; // 版本描述
link: string; // 详情链接
tag?: string; // 版本标签(可选)
}
const userStore = useUserStore();
// 当前通知公告列表
const vesionList = ref<VersionItem[]>([
{
id: "1",
title: "v2.4.0",
date: "2021-09-01 00:00:00",
content: "实现基础框架搭建,包含权限管理、路由系统等核心功能。",
link: "https://gitee.com/youlaiorg/vue3-element-admin/releases",
tag: "里程碑",
},
{
id: "1",
title: "v2.4.0",
date: "2021-09-01 00:00:00",
content: "实现基础框架搭建,包含权限管理、路由系统等核心功能。",
link: "https://gitee.com/youlaiorg/vue3-element-admin/releases",
tag: "里程碑",
},
{
id: "1",
title: "v2.4.0",
date: "2021-09-01 00:00:00",
content: "实现基础框架搭建,包含权限管理、路由系统等核心功能。",
link: "https://gitee.com/youlaiorg/vue3-element-admin/releases",
tag: "里程碑",
},
]);
// 当前时间(用于计算问候语)
const currentDate = new Date();
// 问候语:根据当前小时返回不同问候语
const greetings = computed(() => {
const hours = currentDate.getHours();
const nickname = userStore.userInfo.nickname;
if (hours >= 6 && hours < 8) {
return "晨起披衣出草堂,轩窗已自喜微凉🌅!";
} else if (hours >= 8 && hours < 12) {
return `上午好,${nickname}`;
} else if (hours >= 12 && hours < 18) {
return `下午好,${nickname}`;
} else if (hours >= 18 && hours < 24) {
return `晚上好,${nickname}`;
} else {
return "偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!";
}
});
// 访客统计数据加载状态
const visitStatsLoading = ref(true);
// 访客统计数据
const visitStatsData = ref<VisitStatsVO>({
todayUvCount: 0,
uvGrowthRate: 0,
totalUvCount: 0,
todayPvCount: 0,
pvGrowthRate: 0,
totalPvCount: 0,
});
// 数字过渡动画
const transitionUvCount = useTransition(
computed(() => visitStatsData.value.todayUvCount),
{
duration: 1000,
transition: [0.25, 0.1, 0.25, 1.0], // CSS cubic-bezier
}
);
const transitionTotalUvCount = useTransition(
computed(() => visitStatsData.value.totalUvCount),
{
duration: 1200,
transition: [0.25, 0.1, 0.25, 1.0],
}
);
const transitionPvCount = useTransition(
computed(() => visitStatsData.value.todayPvCount),
{
duration: 1000,
transition: [0.25, 0.1, 0.25, 1.0],
}
);
const transitionTotalPvCount = useTransition(
computed(() => visitStatsData.value.totalPvCount),
{
duration: 1200,
transition: [0.25, 0.1, 0.25, 1.0],
}
);
// 访问趋势日期范围(单位:天)
const visitTrendDateRange = ref(7);
// 访问趋势图表配置
const visitTrendChartOptions = ref();
/**
* 获取访客统计数据
*/
const fetchVisitStatsData = () => {
LogAPI.getVisitStats()
.then((data) => {
visitStatsData.value = data;
})
.finally(() => {
visitStatsLoading.value = false;
});
};
/**
* 获取访问趋势数据,并更新图表配置
*/
const fetchVisitTrendData = () => {
const startDate = dayjs()
.subtract(visitTrendDateRange.value - 1, "day")
.toDate();
const endDate = new Date();
LogAPI.getVisitTrend({
startDate: dayjs(startDate).format("YYYY-MM-DD"),
endDate: dayjs(endDate).format("YYYY-MM-DD"),
}).then((data) => {
updateVisitTrendChartOptions(data);
});
};
/**
* 更新访问趋势图表的配置项
*
* @param data - 访问趋势数据
*/
const updateVisitTrendChartOptions = (data: VisitTrendVO) => {
console.log("Updating visit trend chart options");
visitTrendChartOptions.value = {
tooltip: {
trigger: "axis",
},
legend: {
data: ["浏览量(PV)", "访客数(UV)"],
bottom: 0,
},
grid: {
left: "1%",
right: "5%",
bottom: "10%",
containLabel: true,
},
xAxis: {
type: "category",
data: data.dates,
},
yAxis: {
type: "value",
splitLine: {
show: true,
lineStyle: {
type: "dashed",
},
},
},
series: [
{
name: "浏览量(PV)",
type: "line",
data: data.pvList,
areaStyle: {
color: "rgba(64, 158, 255, 0.1)",
},
smooth: true,
itemStyle: {
color: "#4080FF",
},
lineStyle: {
color: "#4080FF",
},
},
{
name: "访客数(UV)",
type: "line",
data: data.ipList,
areaStyle: {
color: "rgba(103, 194, 58, 0.1)",
},
smooth: true,
itemStyle: {
color: "#67C23A",
},
lineStyle: {
color: "#67C23A",
},
},
],
};
};
/**
* 根据增长率计算对应的 CSS 类名
*
* @param growthRate - 增长率数值
*/
const computeGrowthRateClass = (growthRate?: number): string => {
if (!growthRate) {
return "text-[--el-color-info]";
}
if (growthRate > 0) {
return "text-[--el-color-danger]";
} else if (growthRate < 0) {
return "text-[--el-color-success]";
} else {
return "text-[--el-color-info]";
}
};
// 监听访问趋势日期范围的变化,重新获取趋势数据
watch(
() => visitTrendDateRange.value,
(newVal) => {
console.log("Visit trend date range changed:", newVal);
fetchVisitTrendData();
},
{ immediate: true }
);
// 组件挂载后加载访客统计数据和通知公告数据
onMounted(() => {
fetchVisitStatsData();
});
</script>
<style lang="scss" scoped>
.dashboard-container {
position: relative;
padding: 24px;
.github-corner {
position: absolute;
top: 0;
right: 0;
z-index: 1;
border: 0;
}
.version-item {
padding: 16px;
margin-bottom: 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
transition: all 0.2s;
&.latest-item {
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
}
&:hover {
transform: translateX(5px);
}
.version-content {
margin-bottom: 12px;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
}
}
</style>