feat(frontend): 新增设备型号版本分布统计与展示模块

- 后端新增接口,按型号统计全量设备版本分布,空版本计入“未知版本”
- 前端API添加获取设备版本分布的请求方法
- 首页新增各型号版本占比的饼图展示,支持展开查看完整版本列表
- 饼图支持高亮显示前10大版本,其他版本合并显示
- 设备版本数据加载失败时提供重试功能
- 优化客服反馈状态下拉选项类型定义及默认值
- 组件重构饼图模块,增强易用性和可访问性
This commit is contained in:
eafonyang
2026-09-21 15:55:09 +08:00
parent e6da74cf01
commit d82632414a
6 changed files with 230 additions and 96 deletions
+42
View File
@@ -268,4 +268,46 @@ router.get('/api/dashboard/active-daily', async (req: Request, res: Response) =>
}
});
/**
* GET /api/dashboard/device-versions
* 按型号统计全量设备的版本分布,空版本计入“未知版本”
*/
router.get('/api/dashboard/device-versions', async (_req: Request, res: Response) => {
try {
const version = sql<string>`COALESCE(NULLIF(TRIM(${userDevices.ver}), ''), '未知版本')`;
const rows = await db
.select({
model: userDevices.model,
ver: version.as('version'),
count: sql<number>`COUNT(*)`.mapWith(Number).as('count'),
})
.from(userDevices)
.groupBy(userDevices.model, version)
.orderBy(userDevices.model, desc(sql`COUNT(*)`), version);
const modelMap = new Map<string, {
model: string;
total: number;
versions: Array<{ ver: string; count: number }>;
}>();
for (const row of rows) {
let stats = modelMap.get(row.model);
if (!stats) {
stats = { model: row.model, total: 0, versions: [] };
modelMap.set(row.model, stats);
}
const count = Number(row.count);
stats.total += count;
stats.versions.push({ ver: row.ver, count });
}
res.json(ApiResponse.success({ items: [...modelMap.values()] }));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger.error(`获取设备版本分布失败:${msg}`);
res.json(ApiResponse.error(`获取设备版本分布失败:${msg}`));
}
});
export default router;
@@ -15,6 +15,13 @@ export function fetchGetDeviceDaily(days: number = 30) {
});
}
export function fetchGetDeviceVersions() {
return request<Api.Dashboard.DeviceVersionStats>({
url: '/dashboard/device-versions',
method: 'get'
});
}
export function fetchGetActiveDaily(days: number = 30) {
return request<Api.Dashboard.ActiveDailyStats>({
url: '/dashboard/active-daily',
+13
View File
@@ -49,6 +49,19 @@ declare namespace Api {
total: number;
}
interface DeviceVersionModel {
model: string;
total: number;
versions: Array<{
ver: string;
count: number;
}>;
}
interface DeviceVersionStats {
items: DeviceVersionModel[];
}
interface ActiveDailyStats {
dates: string[];
series: DeviceDailySeries[];
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { NButton, NDrawer, NDrawerContent, NInput, NSelect, NSpace, NTag, type DataTableColumns } from 'naive-ui';
import { NButton, NDrawer, NDrawerContent, NInput, NSelect, NSpace, NTag, type DataTableColumns, type SelectOption } from 'naive-ui';
import {
fetchGetSupportFeedbacks,
fetchGetSupportFeedbackMessages,
@@ -38,8 +38,8 @@ const pagination = reactive({
pageSizes: [10, 20, 50],
});
const statusOptions = [
{ label: '全部', value: null },
const statusOptions: SelectOption[] = [
{ label: '全部', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
+44 -1
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { fetchGetDashboardToday, fetchGetDeviceDaily, fetchGetActiveDaily } from '@/service/api';
import { fetchGetDashboardToday, fetchGetDeviceDaily, fetchGetActiveDaily, fetchGetDeviceVersions } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useThemeStore } from '@/store/modules/theme';
import { useEcharts } from '@/hooks/common/echarts';
import type { ECOption } from '@/hooks/common/echarts';
import PieChart from './modules/pie-chart.vue';
defineOptions({ name: 'Home' });
@@ -269,6 +270,28 @@ watch(activeDays, () => {
loadActiveDaily();
});
// ===== 各型号版本占比(全量设备) =====
const versionLoading = ref(false);
const versionLoadFailed = ref(false);
const deviceVersions = ref<Api.Dashboard.DeviceVersionModel[]>([]);
async function loadDeviceVersions() {
versionLoading.value = true;
versionLoadFailed.value = false;
try {
const { data, error } = await fetchGetDeviceVersions();
if (error || !data) {
versionLoadFailed.value = true;
return;
}
deviceVersions.value = data.items;
} catch {
versionLoadFailed.value = true;
} finally {
versionLoading.value = false;
}
}
// 主题切换时用已有数据重建图表(颜色自适应)
watch(isDark, () => {
nextTick(() => {
@@ -285,6 +308,7 @@ onMounted(() => {
loadTodayStats();
loadDeviceDaily();
loadActiveDaily();
loadDeviceVersions();
});
</script>
@@ -375,6 +399,25 @@ onMounted(() => {
</NCard>
</NGi>
</NGrid>
<NCard :bordered="false" class="card-wrapper" title="各型号版本占比">
<NSpace vertical :size="16">
<NText depth="3">按各型号全部设备统计,未上报版本号的设备计入“未知版本”。</NText>
<NSpin :show="versionLoading">
<NEmpty v-if="versionLoadFailed" description="版本分布加载失败">
<template #extra>
<NButton size="small" @click="loadDeviceVersions">重试</NButton>
</template>
</NEmpty>
<NGrid v-else-if="deviceVersions.length" cols="1 m:2 xl:3" responsive="screen" :x-gap="16" :y-gap="16">
<NGi v-for="item in deviceVersions" :key="item.model">
<PieChart :stats="item" />
</NGi>
</NGrid>
<NEmpty v-else :description="versionLoading ? '正在加载版本分布' : '暂无设备版本数据'" />
</NSpin>
</NSpace>
</NCard>
</NSpace>
</template>
@@ -1,108 +1,137 @@
<script setup lang="ts">
import { watch } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { computed, ref, useId, watch } from 'vue';
import type { DataTableColumns } from 'naive-ui';
import { useEcharts } from '@/hooks/common/echarts';
import { $t } from '@/locales';
import type { ECOption } from '@/hooks/common/echarts';
defineOptions({
name: 'PieChart'
});
const appStore = useAppStore();
const props = defineProps<{
stats: Api.Dashboard.DeviceVersionModel;
}>();
const { domRef, updateOptions } = useEcharts(() => ({
const TOP_VERSION_LIMIT = 10;
const expanded = ref(false);
const versionListId = useId();
const sortedVersions = computed(() =>
[...props.stats.versions].sort((a, b) => b.count - a.count || a.ver.localeCompare(b.ver))
);
const topVersions = computed(() => sortedVersions.value.slice(0, TOP_VERSION_LIMIT));
const remainingCount = computed(() =>
sortedVersions.value.slice(TOP_VERSION_LIMIT).reduce((sum, item) => sum + item.count, 0)
);
function formatPercent(count: number) {
return props.stats.total > 0 ? `${((count / props.stats.total) * 100).toFixed(2)}%` : '0.00%';
}
const versionColumns: DataTableColumns<Api.Dashboard.DeviceVersionModel['versions'][number]> = [
{ title: '版本号', key: 'ver', ellipsis: { tooltip: true } },
{ title: '用户数量', key: 'count', align: 'right', width: 90 },
{ title: '占比', key: 'percent', align: 'right', width: 90, render: row => formatPercent(row.count) }
];
function buildOptions(): ECOption {
const chartData = topVersions.value.map(item => ({ name: item.ver, value: item.count }));
if (remainingCount.value > 0) {
chartData.push({ name: '其他', value: remainingCount.value });
}
return {
color: ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E8684A', '#6DC8EC', '#9270CA'],
tooltip: {
trigger: 'item'
trigger: 'item',
renderMode: 'richText',
confine: true,
formatter: params => {
const item = Array.isArray(params) ? params[0] : params;
const count = typeof item.value === 'number' ? item.value : 0;
return `${item.name}\n用户数量:${count}\n占比:${formatPercent(count)}`;
}
},
legend: {
bottom: '1%',
type: 'scroll',
bottom: 4,
left: 'center',
itemStyle: {
borderWidth: 0
itemWidth: 12,
itemHeight: 8,
itemGap: 16,
// 保持占比分母为该型号全部设备,不随图例点击变化。
selectedMode: false,
formatter: name => {
const count = chartData.find(item => item.name === name)?.value ?? 0;
return `${name} (${formatPercent(count)})`;
}
},
series: [
{
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
name: $t('page.home.schedule'),
name: props.stats.model,
type: 'pie',
radius: ['45%', '75%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 1
},
label: {
show: false,
position: 'center'
},
emphasis: {
// 标签避让范围限定在饼图区内,底部单独留给滚动图例。
top: 8,
bottom: 56,
radius: '58%',
center: ['50%', '50%'],
startAngle: 90,
endAngle: 'auto',
stillShowZeroSum: false,
avoidLabelOverlap: true,
label: {
show: true,
fontSize: '12'
}
formatter: params => `${params.name}\n${formatPercent(typeof params.value === 'number' ? params.value : 0)}`,
fontSize: 12,
lineHeight: 18,
alignTo: 'edge',
edgeDistance: 8
},
labelLine: {
show: false
},
data: [] as { name: string; value: number }[]
labelLayout: { hideOverlap: true },
labelLine: { length: 12, length2: 8 },
data: chartData
}
]
}));
async function mockData() {
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
updateOptions(opts => {
opts.series[0].data = [
{ name: $t('page.home.study'), value: 20 },
{ name: $t('page.home.entertainment'), value: 10 },
{ name: $t('page.home.work'), value: 40 },
{ name: $t('page.home.rest'), value: 30 }
];
return opts;
});
};
}
function updateLocale() {
updateOptions((opts, factory) => {
const originOpts = factory();
opts.series[0].name = originOpts.series[0].name;
opts.series[0].data = [
{ name: $t('page.home.study'), value: 20 },
{ name: $t('page.home.entertainment'), value: 10 },
{ name: $t('page.home.work'), value: 40 },
{ name: $t('page.home.rest'), value: 30 }
];
return opts;
});
}
async function init() {
mockData();
}
const { domRef, updateOptions } = useEcharts(buildOptions);
watch(
() => appStore.locale,
() => {
updateLocale();
}
() => props.stats,
() => updateOptions(buildOptions),
{ deep: true }
);
// init
init();
</script>
<template>
<NCard :bordered="false" class="card-wrapper">
<div ref="domRef" class="h-360px overflow-hidden"></div>
<NCard :title="stats.model" size="small">
<template #header-extra>
<NTag type="info" size="small">共 {{ stats.total }} 台</NTag>
</template>
<div ref="domRef" class="h-360px w-full overflow-hidden"></div>
<NSpace vertical :size="12" class="mt-12px">
<NText depth="3" class="text-12px">
展示用户数最多的前 {{ TOP_VERSION_LIMIT }} 个版本,其余版本合并为“其他”,占比按该型号全部用户计算。
<template v-if="remainingCount > 0">“其他”共 {{ remainingCount }} 台({{ formatPercent(remainingCount) }})。</template>
</NText>
<NButton
size="small"
:aria-expanded="expanded"
:aria-controls="versionListId"
@click="expanded = !expanded"
>
{{ expanded ? '收起版本列表' : `展开全部版本(${sortedVersions.length})` }}
</NButton>
<div v-if="expanded" :id="versionListId">
<NDataTable
:columns="versionColumns"
:data="sortedVersions"
:row-key="row => row.ver"
:max-height="300"
:bordered="false"
size="small"
/>
</div>
</NSpace>
</NCard>
</template>