feat(frontend): 新增设备型号版本分布统计与展示模块
- 后端新增接口,按型号统计全量设备版本分布,空版本计入“未知版本” - 前端API添加获取设备版本分布的请求方法 - 首页新增各型号版本占比的饼图展示,支持展开查看完整版本列表 - 饼图支持高亮显示前10大版本,其他版本合并显示 - 设备版本数据加载失败时提供重试功能 - 优化客服反馈状态下拉选项类型定义及默认值 - 组件重构饼图模块,增强易用性和可访问性
This commit is contained in:
@@ -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;
|
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) {
|
export function fetchGetActiveDaily(days: number = 30) {
|
||||||
return request<Api.Dashboard.ActiveDailyStats>({
|
return request<Api.Dashboard.ActiveDailyStats>({
|
||||||
url: '/dashboard/active-daily',
|
url: '/dashboard/active-daily',
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ declare namespace Api {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeviceVersionModel {
|
||||||
|
model: string;
|
||||||
|
total: number;
|
||||||
|
versions: Array<{
|
||||||
|
ver: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceVersionStats {
|
||||||
|
items: DeviceVersionModel[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ActiveDailyStats {
|
interface ActiveDailyStats {
|
||||||
dates: string[];
|
dates: string[];
|
||||||
series: DeviceDailySeries[];
|
series: DeviceDailySeries[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
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 {
|
import {
|
||||||
fetchGetSupportFeedbacks,
|
fetchGetSupportFeedbacks,
|
||||||
fetchGetSupportFeedbackMessages,
|
fetchGetSupportFeedbackMessages,
|
||||||
@@ -38,8 +38,8 @@ const pagination = reactive({
|
|||||||
pageSizes: [10, 20, 50],
|
pageSizes: [10, 20, 50],
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions: SelectOption[] = [
|
||||||
{ label: '全部', value: null },
|
{ label: '全部', value: '' },
|
||||||
{ label: '待处理', value: 'pending' },
|
{ label: '待处理', value: 'pending' },
|
||||||
{ label: '处理中', value: 'processing' },
|
{ label: '处理中', value: 'processing' },
|
||||||
{ label: '已解决', value: 'resolved' },
|
{ label: '已解决', value: 'resolved' },
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch, nextTick } from 'vue';
|
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 { useAuthStore } from '@/store/modules/auth';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useEcharts } from '@/hooks/common/echarts';
|
import { useEcharts } from '@/hooks/common/echarts';
|
||||||
import type { ECOption } from '@/hooks/common/echarts';
|
import type { ECOption } from '@/hooks/common/echarts';
|
||||||
|
import PieChart from './modules/pie-chart.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'Home' });
|
defineOptions({ name: 'Home' });
|
||||||
|
|
||||||
@@ -269,6 +270,28 @@ watch(activeDays, () => {
|
|||||||
loadActiveDaily();
|
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, () => {
|
watch(isDark, () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -285,6 +308,7 @@ onMounted(() => {
|
|||||||
loadTodayStats();
|
loadTodayStats();
|
||||||
loadDeviceDaily();
|
loadDeviceDaily();
|
||||||
loadActiveDaily();
|
loadActiveDaily();
|
||||||
|
loadDeviceVersions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -375,6 +399,25 @@ onMounted(() => {
|
|||||||
</NCard>
|
</NCard>
|
||||||
</NGi>
|
</NGi>
|
||||||
</NGrid>
|
</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>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +1,137 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue';
|
import { computed, ref, useId, watch } from 'vue';
|
||||||
import { useAppStore } from '@/store/modules/app';
|
import type { DataTableColumns } from 'naive-ui';
|
||||||
import { useEcharts } from '@/hooks/common/echarts';
|
import { useEcharts } from '@/hooks/common/echarts';
|
||||||
import { $t } from '@/locales';
|
import type { ECOption } from '@/hooks/common/echarts';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'PieChart'
|
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: {
|
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: {
|
legend: {
|
||||||
bottom: '1%',
|
type: 'scroll',
|
||||||
|
bottom: 4,
|
||||||
left: 'center',
|
left: 'center',
|
||||||
itemStyle: {
|
itemWidth: 12,
|
||||||
borderWidth: 0
|
itemHeight: 8,
|
||||||
|
itemGap: 16,
|
||||||
|
// 保持占比分母为该型号全部设备,不随图例点击变化。
|
||||||
|
selectedMode: false,
|
||||||
|
formatter: name => {
|
||||||
|
const count = chartData.find(item => item.name === name)?.value ?? 0;
|
||||||
|
return `${name} (${formatPercent(count)})`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
|
name: props.stats.model,
|
||||||
name: $t('page.home.schedule'),
|
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['45%', '75%'],
|
// 标签避让范围限定在饼图区内,底部单独留给滚动图例。
|
||||||
avoidLabelOverlap: false,
|
top: 8,
|
||||||
itemStyle: {
|
bottom: 56,
|
||||||
borderRadius: 10,
|
radius: '58%',
|
||||||
borderColor: '#fff',
|
center: ['50%', '50%'],
|
||||||
borderWidth: 1
|
startAngle: 90,
|
||||||
},
|
endAngle: 'auto',
|
||||||
label: {
|
stillShowZeroSum: false,
|
||||||
show: false,
|
avoidLabelOverlap: true,
|
||||||
position: 'center'
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
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: {
|
labelLayout: { hideOverlap: true },
|
||||||
show: false
|
labelLine: { length: 12, length2: 8 },
|
||||||
},
|
data: chartData
|
||||||
data: [] as { name: string; value: number }[]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}));
|
};
|
||||||
|
|
||||||
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() {
|
const { domRef, updateOptions } = useEcharts(buildOptions);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => appStore.locale,
|
() => props.stats,
|
||||||
() => {
|
() => updateOptions(buildOptions),
|
||||||
updateLocale();
|
{ deep: true }
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// init
|
|
||||||
init();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NCard :bordered="false" class="card-wrapper">
|
<NCard :title="stats.model" size="small">
|
||||||
<div ref="domRef" class="h-360px overflow-hidden"></div>
|
<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>
|
</NCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user