首页新增新增&日活报表
This commit is contained in:
@@ -6,3 +6,19 @@ export function fetchGetDashboardToday() {
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchGetDeviceDaily(days: number = 30) {
|
||||
return request<Api.Dashboard.DeviceDailyStats>({
|
||||
url: '/dashboard/device-daily',
|
||||
method: 'get',
|
||||
params: { days }
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchGetActiveDaily(days: number = 30) {
|
||||
return request<Api.Dashboard.ActiveDailyStats>({
|
||||
url: '/dashboard/active-daily',
|
||||
method: 'get',
|
||||
params: { days }
|
||||
});
|
||||
}
|
||||
|
||||
+20
@@ -37,6 +37,26 @@ declare namespace Api {
|
||||
}>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DeviceDailySeries {
|
||||
name: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
interface DeviceDailyStats {
|
||||
dates: string[];
|
||||
series: DeviceDailySeries[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ActiveDailyStats {
|
||||
dates: string[];
|
||||
series: DeviceDailySeries[];
|
||||
latest: {
|
||||
date: string;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
namespace User {
|
||||
|
||||
Vendored
+4
@@ -67,6 +67,8 @@ declare module 'vue' {
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
@@ -152,6 +154,8 @@ declare global {
|
||||
const NModal: typeof import('naive-ui')['NModal']
|
||||
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
const NPagination: typeof import('naive-ui')['NPagination']
|
||||
const NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
const NSelect: typeof import('naive-ui')['NSelect']
|
||||
const NSpace: typeof import('naive-ui')['NSpace']
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { fetchGetDashboardToday } from '@/service/api';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { fetchGetDashboardToday, fetchGetDeviceDaily, fetchGetActiveDaily } from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
import type { ECOption } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({ name: 'Home' });
|
||||
|
||||
@@ -21,15 +21,7 @@ interface TodayOta {
|
||||
create_at?: string | null;
|
||||
}
|
||||
|
||||
interface QuickLink {
|
||||
key: RouteKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const loading = ref(false);
|
||||
const todayModels = ref<TodayModel[]>([]);
|
||||
const todayOtas = ref<TodayOta[]>([]);
|
||||
@@ -37,38 +29,6 @@ const todayOtas = ref<TodayOta[]>([]);
|
||||
const modelCount = computed(() => todayModels.value.length);
|
||||
const otaCount = computed(() => todayOtas.value.length);
|
||||
|
||||
const quickLinks: QuickLink[] = [
|
||||
{
|
||||
key: 'headphone_brand',
|
||||
title: '耳机品牌管理',
|
||||
desc: '维护耳机品牌信息',
|
||||
icon: 'mdi:tag-multiple-outline'
|
||||
},
|
||||
{
|
||||
key: 'headphone_model',
|
||||
title: '耳机型号管理',
|
||||
desc: '维护耳机型号与配置',
|
||||
icon: 'mdi:earbuds'
|
||||
},
|
||||
{
|
||||
key: 'upgrade_ota',
|
||||
title: 'OTA 管理',
|
||||
desc: '固件升级包管理',
|
||||
icon: 'mdi:cellphone-arrow-down'
|
||||
},
|
||||
{
|
||||
key: 'share-code_log',
|
||||
title: '分享日志',
|
||||
desc: '查看分享码使用记录',
|
||||
icon: 'mdi:file-document-outline'
|
||||
},
|
||||
{
|
||||
key: 'toolbox_luxsin-controller',
|
||||
title: 'Luxsin 控制器',
|
||||
desc: '局域网设备数据同步',
|
||||
icon: 'mdi:tune-vertical'
|
||||
}
|
||||
];
|
||||
|
||||
function formatTime(isoStr?: string | null) {
|
||||
if (!isoStr) return '';
|
||||
@@ -87,12 +47,149 @@ async function loadTodayStats() {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function go(key: RouteKey) {
|
||||
routerPushByKey(key);
|
||||
// ===== 共享:tooltip 悬浮时追加总计行(可自定义标签)=====
|
||||
function createTooltipFormatter(totalLabel: string) {
|
||||
return (params: any): string => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
if (!list.length) return '';
|
||||
const header = `${list[0].axisValueLabel || list[0].name}<br/>`;
|
||||
let total = 0;
|
||||
const items = list
|
||||
.map(p => {
|
||||
const val = typeof p.value === 'number' ? p.value : 0;
|
||||
total += val;
|
||||
return `${p.marker} ${p.seriesName}: <b>${val}</b>`;
|
||||
})
|
||||
.join('<br/>');
|
||||
return `${header}${items}<br/>${totalLabel}: <b>${total}</b>`;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 设备每日新增趋势 =====
|
||||
const deviceDays = ref(30);
|
||||
const deviceLoading = ref(false);
|
||||
const deviceTotal = ref(0);
|
||||
|
||||
function buildDeviceChartOptions(dates: string[], series: Array<{ name: string; data: number[] }>): ECOption {
|
||||
const labelInterval = deviceDays.value <= 7 ? 0 : deviceDays.value <= 30 ? 2 : 6;
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: createTooltipFormatter('总新增')
|
||||
},
|
||||
legend: {
|
||||
top: 'bottom'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '12%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
formatter: (val: string) => val.slice(5),
|
||||
interval: labelInterval,
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1
|
||||
},
|
||||
series: series.map(s => ({
|
||||
name: s.name,
|
||||
type: 'bar' as const,
|
||||
stack: 'total',
|
||||
data: s.data
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const { domRef: deviceChartRef, updateOptions: updateDeviceChart } = useEcharts(() => buildDeviceChartOptions([], []));
|
||||
|
||||
async function loadDeviceDaily() {
|
||||
deviceLoading.value = true;
|
||||
const { data, error } = await fetchGetDeviceDaily(deviceDays.value);
|
||||
if (!error && data) {
|
||||
deviceTotal.value = data.total;
|
||||
await updateDeviceChart(() => buildDeviceChartOptions(data.dates, data.series));
|
||||
}
|
||||
deviceLoading.value = false;
|
||||
}
|
||||
|
||||
watch(deviceDays, () => {
|
||||
loadDeviceDaily();
|
||||
});
|
||||
|
||||
// ===== 设备日活趋势 =====
|
||||
const activeDays = ref(30);
|
||||
const activeLoading = ref(false);
|
||||
const activeLatest = ref({ date: '', total: 0 });
|
||||
|
||||
function buildActiveChartOptions(dates: string[], series: Array<{ name: string; data: number[] }>): ECOption {
|
||||
const labelInterval = activeDays.value <= 7 ? 0 : activeDays.value <= 30 ? 2 : 6;
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: createTooltipFormatter('总日活')
|
||||
},
|
||||
legend: {
|
||||
top: 'bottom'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '12%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
formatter: (val: string) => val.slice(5),
|
||||
interval: labelInterval,
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1
|
||||
},
|
||||
series: series.map(s => ({
|
||||
name: s.name,
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
symbolSize: 6,
|
||||
data: s.data
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
const { domRef: activeChartRef, updateOptions: updateActiveChart } = useEcharts(() => buildActiveChartOptions([], []));
|
||||
|
||||
async function loadActiveDaily() {
|
||||
activeLoading.value = true;
|
||||
const { data, error } = await fetchGetActiveDaily(activeDays.value);
|
||||
if (!error && data) {
|
||||
activeLatest.value = data.latest;
|
||||
await updateActiveChart(() => buildActiveChartOptions(data.dates, data.series));
|
||||
}
|
||||
activeLoading.value = false;
|
||||
}
|
||||
|
||||
watch(activeDays, () => {
|
||||
loadActiveDaily();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadTodayStats();
|
||||
loadDeviceDaily();
|
||||
loadActiveDaily();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -101,30 +198,10 @@ onMounted(() => {
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<NThing>
|
||||
<template #header>欢迎回来,{{ authStore.userInfo.userName || '用户' }}</template>
|
||||
<template #description>Luxsin CMS · 从下方快捷入口或左侧菜单开始操作</template>
|
||||
<template #description>Luxsin CMS · 从左侧菜单开始操作</template>
|
||||
</NThing>
|
||||
</NCard>
|
||||
|
||||
<NGrid cols="1 s:2 m:3" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi v-for="item in quickLinks" :key="item.key">
|
||||
<NCard
|
||||
:bordered="false"
|
||||
class="card-wrapper quick-link-card cursor-pointer"
|
||||
hoverable
|
||||
@click="go(item.key)"
|
||||
>
|
||||
<div class="flex-y-center gap-12px">
|
||||
<div class="quick-link-icon">
|
||||
<SvgIcon :icon="item.icon" class="text-24px" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-16px font-medium">{{ item.title }}</div>
|
||||
<NText depth="3" class="text-13px">{{ item.desc }}</NText>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NGrid cols="1 s:2" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
@@ -162,26 +239,51 @@ onMounted(() => {
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NSpin>
|
||||
|
||||
<NGrid cols="1 s:2" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<template #header>设备每日新增趋势</template>
|
||||
<template #header-extra>
|
||||
<NSpace align="center" :size="12">
|
||||
<NTag type="info" size="small">合计 {{ deviceTotal }}</NTag>
|
||||
<NRadioGroup v-model:value="deviceDays" size="small">
|
||||
<NRadioButton :value="7">近7天</NRadioButton>
|
||||
<NRadioButton :value="30">近1月</NRadioButton>
|
||||
<NRadioButton :value="90">近3月</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
</template>
|
||||
<NSpin :show="deviceLoading">
|
||||
<div ref="deviceChartRef" class="device-chart-container"></div>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<template #header>设备日活趋势</template>
|
||||
<template #header-extra>
|
||||
<NSpace align="center" :size="12">
|
||||
<NTag type="success" size="small">{{ activeLatest.date.slice(5) }} 总日活 {{ activeLatest.total }}</NTag>
|
||||
<NRadioGroup v-model:value="activeDays" size="small">
|
||||
<NRadioButton :value="7">近7天</NRadioButton>
|
||||
<NRadioButton :value="30">近1月</NRadioButton>
|
||||
<NRadioButton :value="90">近3月</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
</template>
|
||||
<NSpin :show="activeLoading">
|
||||
<div ref="activeChartRef" class="device-chart-container"></div>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-link-card {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-link-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.quick-link-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(125, 211, 252, 0.12);
|
||||
color: #38bdf8;
|
||||
.device-chart-container {
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user