优化首页报表

This commit is contained in:
eafonyang
2026-07-24 15:12:15 +08:00
parent 3a0108dc54
commit 22a24056d6
18 changed files with 1312 additions and 184 deletions
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 761 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+18 -9
View File
@@ -58,7 +58,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'headphone_brand',
i18nKey: 'route.headphone_brand',
icon: 'mdi:tag-multiple-outline',
order: 1
order: 1,
keepAlive: true
}
},
{
@@ -69,7 +70,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'headphone_model',
i18nKey: 'route.headphone_model',
icon: 'mdi:earbuds',
order: 2
order: 2,
keepAlive: true
}
}
]
@@ -82,7 +84,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'home',
i18nKey: 'route.home',
icon: 'mdi:monitor-dashboard',
order: 1
order: 1,
keepAlive: true
}
},
{
@@ -129,7 +132,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'share-code_log',
i18nKey: 'route.share-code_log',
icon: 'mdi:file-document-outline',
order: 1
order: 1,
keepAlive: true
}
}
]
@@ -155,7 +159,8 @@ export const generatedRoutes: GeneratedRoute[] = [
i18nKey: 'route.system_users',
icon: 'mdi:account-cog-outline',
order: 1,
roles: ['R_SUPER']
roles: ['R_SUPER'],
keepAlive: true
}
}
]
@@ -179,7 +184,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'toolbox_luxsin-controller',
i18nKey: 'route.toolbox_luxsin-controller',
icon: 'mdi:tune-vertical',
order: 1
order: 1,
keepAlive: true
}
}
]
@@ -203,7 +209,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'upgrade_blacklist',
i18nKey: 'route.upgrade_blacklist',
icon: 'mdi:account-cancel-outline',
order: 3
order: 3,
keepAlive: true
}
},
{
@@ -214,7 +221,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'upgrade_ota',
i18nKey: 'route.upgrade_ota',
icon: 'mdi:cellphone-arrow-down',
order: 1
order: 1,
keepAlive: true
}
},
{
@@ -225,7 +233,8 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'upgrade_ota-target-device',
i18nKey: 'route.upgrade_ota-target-device',
icon: 'mdi:crosshairs-gps',
order: 2
order: 2,
keepAlive: true
}
}
]
+18
View File
@@ -103,6 +103,24 @@ export function fetchPushToMeilisearch(modelIds: number[]) {
});
}
export function fetchBatchUpdateModels(data: {
ids: number[];
brand_name?: string;
form?: string;
source?: string;
rig?: string;
}) {
return request<{
updated_count: number;
updated_ids: number[];
errors: Array<{ id: number; brand_name: string; name: string; error: string }>;
}>({
url: '/models/batch-update',
method: 'patch',
data
});
}
export function fetchFromSquigLink(shareUrl: string, selectedFile?: string) {
return request({
url: '/models/squiglink-fetch',
+2
View File
@@ -67,6 +67,7 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination']
NProgress: typeof import('naive-ui')['NProgress']
NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
@@ -154,6 +155,7 @@ declare global {
const NModal: typeof import('naive-ui')['NModal']
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
const NPagination: typeof import('naive-ui')['NPagination']
const NProgress: typeof import('naive-ui')['NProgress']
const NRadioButton: typeof import('naive-ui')['NRadioButton']
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
const NScrollbar: typeof import('naive-ui')['NScrollbar']
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import type { FormInst } from 'naive-ui';
import { fetchBatchUpdateModels } from '@/service/api';
import type { BrandOption } from '../composables/useModelList';
const props = defineProps<{
show: boolean;
brandOptions?: BrandOption[];
selectedIds: number[];
}>();
const emit = defineEmits<{
'update:show': [value: boolean];
success: [];
'reload-brands': [];
}>();
const formRef = ref<FormInst | null>(null);
const submitLoading = ref(false);
const progress = reactive({
show: false,
current: 0,
total: 0
});
const formData = reactive({
brand_name: '',
form: null as string | null,
source: '',
rig: ''
});
const brandSelectOptions = computed(() => {
const list = (props.brandOptions || []).map(b => ({ label: b.name, value: b.name }));
const q = (formData.brand_name || '').trim().toLowerCase();
if (!q) return list;
return list.filter(item => item.label.toLowerCase().includes(q));
});
const hasBrand = computed(() => formData.brand_name.trim().length > 0);
const hasForm = computed(() => !!formData.form);
const hasSource = computed(() => formData.source.trim().length > 0);
const hasRig = computed(() => formData.rig.trim().length > 0);
const canSubmit = computed(() => {
if (!props.selectedIds.length) return false;
return hasBrand.value || hasForm.value || hasSource.value || hasRig.value;
});
const progressPercentage = computed(() => {
if (!progress.total) return 0;
return Math.round((progress.current / progress.total) * 100);
});
function resetForm() {
formData.brand_name = '';
formData.form = null;
formData.source = '';
formData.rig = '';
progress.show = false;
progress.current = 0;
progress.total = 0;
formRef.value?.restoreValidation();
}
function handleDialogClose() {
resetForm();
}
async function handleSubmit() {
submitLoading.value = true;
progress.show = true;
progress.total = props.selectedIds.length;
progress.current = 0;
const payloadBase: Record<string, unknown> = {};
if (hasBrand.value) payloadBase.brand_name = formData.brand_name.trim();
if (hasForm.value) payloadBase.form = formData.form;
if (hasSource.value) payloadBase.source = formData.source.trim();
if (hasRig.value) payloadBase.rig = formData.rig.trim();
let successCount = 0;
let failCount = 0;
for (const id of props.selectedIds) {
progress.current++;
const { error } = await fetchBatchUpdateModels({
ids: [id],
...payloadBase
} as {
ids: number[];
brand_name?: string;
form?: string;
source?: string;
rig?: string;
});
if (error) {
failCount++;
} else {
successCount++;
}
}
if (failCount > 0) {
window.$message?.warning(`成功修改 ${successCount} 个,失败 ${failCount}`);
} else {
window.$message?.success(`成功修改 ${successCount} 个型号`);
}
if (hasBrand.value) emit('reload-brands');
emit('update:show', false);
emit('success');
submitLoading.value = false;
}
</script>
<template>
<NModal
:show="show"
preset="card"
title="批量修改"
class="w-520px"
:mask-closable="false"
@update:show="emit('update:show', $event)"
@after-leave="handleDialogClose"
>
<div class="mb-12px text-13px opacity-70">
已选择 <b>{{ selectedIds.length }}</b> 个型号填写需要修改的字段留空则不修改
</div>
<NForm ref="formRef" :model="formData" label-placement="left" label-width="100">
<NFormItem label="品牌名称" path="brand_name">
<NAutoComplete
v-model:value="formData.brand_name"
:options="brandSelectOptions"
clearable
placeholder="留空则不修改"
/>
</NFormItem>
<NFormItem label="佩戴方式" path="form">
<NSelect
v-model:value="formData.form"
clearable
placeholder="留空则不修改"
:options="[
{ label: '入耳式', value: 'in-ear' },
{ label: '头戴式', value: 'over-ear' },
{ label: '耳塞式', value: 'earbud' }
]"
/>
</NFormItem>
<NFormItem label="来源" path="source">
<NInput
v-model:value="formData.source"
maxlength="100"
placeholder="留空则不修改"
/>
</NFormItem>
<NFormItem label="阻抗" path="rig">
<NInput
v-model:value="formData.rig"
maxlength="100"
placeholder="留空则不修改"
/>
</NFormItem>
</NForm>
<div v-if="progress.show" class="batch-progress">
<div class="batch-progress-text">
正在修改 {{ progress.current }}/{{ progress.total }}
</div>
<NProgress
type="line"
:percentage="progressPercentage"
:show-indicator="false"
:height="8"
border-radius="4"
/>
</div>
<template #footer>
<NSpace justify="end">
<NButton @click="emit('update:show', false)" :disabled="submitLoading">取消</NButton>
<NButton type="primary" :loading="submitLoading" :disabled="!canSubmit" @click="handleSubmit">
确定修改
</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped>
.batch-progress {
margin-top: 16px;
}
.batch-progress-text {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
text-align: center;
}
</style>
@@ -14,6 +14,7 @@ import {
} from '@/service/api';
import { useModelList, type ModelRow } from './composables/useModelList';
import ModelFormDialog from './components/ModelFormDialog.vue';
import BatchEditDialog from './components/BatchEditDialog.vue';
import PushViewDialog from './components/PushViewDialog.vue';
import EqCacheDialog from './components/EqCacheDialog.vue';
import CsvViewerDialog from './components/CsvViewerDialog.vue';
@@ -55,6 +56,7 @@ const brandAutoOptions = computed(() => {
});
const formDialogVisible = ref(false);
const batchEditDialogVisible = ref(false);
const moreActionLoadingId = ref<number | null>(null);
const pushViewDialogVisible = ref(false);
@@ -206,6 +208,14 @@ const columns = computed<DataTableColumns<ModelRow>>(() => [
}
]);
function handleBatchEdit() {
if (selectedIds.value.length === 0) {
window.$message?.warning('请选择要修改的型号');
return;
}
batchEditDialogVisible.value = true;
}
function handleAdd() {
formDialogRef.value?.openForAdd();
formDialogVisible.value = true;
@@ -373,6 +383,12 @@ onMounted(() => {
</template>
推送搜索 {{ selectedIds.length }}
</NButton>
<NButton type="info" :disabled="selectedIds.length === 0" @click="handleBatchEdit">
<template #icon>
<SvgIcon icon="mdi:square-edit-outline" />
</template>
批量修改 {{ selectedIds.length }}
</NButton>
<NButton type="primary" @click="handleAdd">
<template #icon>
<SvgIcon icon="mdi:plus" />
@@ -424,6 +440,14 @@ onMounted(() => {
@reload-brands="loadBrands"
/>
<BatchEditDialog
v-model:show="batchEditDialogVisible"
:brand-options="brandOptions"
:selected-ids="selectedIds"
@success="loadData"
@reload-brands="loadBrands"
/>
<PushViewDialog
v-model:show="pushViewDialogVisible"
:title="pushViewDialogTitle"
+112 -16
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { fetchGetDashboardToday, fetchGetDeviceDaily, fetchGetActiveDaily } 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';
@@ -22,6 +23,8 @@ interface TodayOta {
}
const authStore = useAuthStore();
const themeStore = useThemeStore();
const isDark = computed(() => themeStore.darkMode);
const loading = ref(false);
const todayModels = ref<TodayModel[]>([]);
const todayOtas = ref<TodayOta[]>([]);
@@ -47,6 +50,27 @@ async function loadTodayStats() {
loading.value = false;
}
// ===== 共享配色 =====
const CHART_COLORS = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E8684A', '#6DC8EC', '#9270CA'];
// ===== 主题自适应轴线颜色 =====
function getAxisColors() {
if (isDark.value) {
return {
splitLine: 'rgba(255,255,255,0.06)',
axisLine: 'rgba(255,255,255,0.1)',
label: 'rgba(255,255,255,0.45)',
pointer: 'rgba(255,255,255,0.15)'
};
}
return {
splitLine: '#e8eaed',
axisLine: '#dcdfe6',
label: '#8492a6',
pointer: '#b2bdc6'
};
}
// ===== 共享:tooltip 悬浮时追加总计行(可自定义标签)=====
function createTooltipFormatter(totalLabel: string) {
return (params: any): string => {
@@ -71,39 +95,57 @@ 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;
const isHourly = deviceDays.value === 1;
const labelInterval = isHourly ? 1 : deviceDays.value <= 7 ? 0 : deviceDays.value <= 30 ? 2 : 6;
const ac = getAxisColors();
return {
color: CHART_COLORS,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: createTooltipFormatter('总新增')
},
legend: {
top: 'bottom'
top: 'top',
itemGap: 20,
icon: 'roundRect',
itemWidth: 12,
itemHeight: 8
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
bottom: '8%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
axisTick: { show: false },
axisLine: { lineStyle: { color: ac.axisLine } },
axisLabel: {
formatter: (val: string) => val.slice(5),
formatter: (val: string) => (isHourly ? val : val.slice(5)),
interval: labelInterval,
rotate: 45
rotate: isHourly ? 0 : 45,
color: ac.label
}
},
yAxis: {
type: 'value',
minInterval: 1
minInterval: 1,
axisTick: { show: false },
axisLine: { show: false },
splitLine: { lineStyle: { type: 'dashed', color: ac.splitLine } },
axisLabel: { color: ac.label }
},
series: series.map(s => ({
name: s.name,
type: 'bar' as const,
stack: 'total',
barMaxWidth: 32,
itemStyle: { borderRadius: [0, 0, 0, 0] },
emphasis: { focus: 'series' },
data: s.data
}))
};
@@ -111,11 +153,14 @@ function buildDeviceChartOptions(dates: string[], series: Array<{ name: string;
const { domRef: deviceChartRef, updateOptions: updateDeviceChart } = useEcharts(() => buildDeviceChartOptions([], []));
const lastDeviceData = ref<{ dates: string[]; series: Array<{ name: string; data: number[] }> }>({ dates: [], series: [] });
async function loadDeviceDaily() {
deviceLoading.value = true;
const { data, error } = await fetchGetDeviceDaily(deviceDays.value);
if (!error && data) {
deviceTotal.value = data.total;
lastDeviceData.value = { dates: data.dates, series: data.series };
await updateDeviceChart(() => buildDeviceChartOptions(data.dates, data.series));
}
deviceLoading.value = false;
@@ -130,41 +175,75 @@ const activeDays = ref(30);
const activeLoading = ref(false);
const activeLatest = ref({ date: '', total: 0 });
const activeLatestLabel = computed(() => {
if (activeDays.value === 1) return `${activeLatest.value.date} 总活跃`;
return `${activeLatest.value.date.slice(5)} 总日活`;
});
function buildActiveChartOptions(dates: string[], series: Array<{ name: string; data: number[] }>): ECOption {
const labelInterval = activeDays.value <= 7 ? 0 : activeDays.value <= 30 ? 2 : 6;
const isHourly = activeDays.value === 1;
const labelInterval = isHourly ? 1 : activeDays.value <= 7 ? 0 : activeDays.value <= 30 ? 2 : 6;
const ac = getAxisColors();
return {
color: CHART_COLORS,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: { type: 'dashed', color: ac.pointer }
},
formatter: createTooltipFormatter('总日活')
},
legend: {
top: 'bottom'
top: 'top',
itemGap: 20,
icon: 'roundRect',
itemWidth: 12,
itemHeight: 8
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
bottom: '8%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisTick: { show: false },
axisLine: { lineStyle: { color: ac.axisLine } },
axisLabel: {
formatter: (val: string) => val.slice(5),
formatter: (val: string) => (isHourly ? val : val.slice(5)),
interval: labelInterval,
rotate: 45
rotate: isHourly ? 0 : 45,
color: ac.label
}
},
yAxis: {
type: 'value',
minInterval: 1
minInterval: 1,
axisTick: { show: false },
axisLine: { show: false },
splitLine: { lineStyle: { type: 'dashed', color: ac.splitLine } },
axisLabel: { color: ac.label }
},
series: series.map(s => ({
name: s.name,
type: 'line' as const,
smooth: true,
symbol: 'circle',
symbolSize: 6,
showSymbol: false,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.08 },
emphasis: {
focus: 'series',
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 }
},
data: s.data
}))
};
@@ -172,11 +251,14 @@ function buildActiveChartOptions(dates: string[], series: Array<{ name: string;
const { domRef: activeChartRef, updateOptions: updateActiveChart } = useEcharts(() => buildActiveChartOptions([], []));
const lastActiveData = ref<{ dates: string[]; series: Array<{ name: string; data: number[] }> }>({ dates: [], series: [] });
async function loadActiveDaily() {
activeLoading.value = true;
const { data, error } = await fetchGetActiveDaily(activeDays.value);
if (!error && data) {
activeLatest.value = data.latest;
lastActiveData.value = { dates: data.dates, series: data.series };
await updateActiveChart(() => buildActiveChartOptions(data.dates, data.series));
}
activeLoading.value = false;
@@ -186,6 +268,18 @@ watch(activeDays, () => {
loadActiveDaily();
});
// 主题切换时用已有数据重建图表(颜色自适应)
watch(isDark, () => {
nextTick(() => {
if (lastDeviceData.value.dates.length) {
updateDeviceChart(() => buildDeviceChartOptions(lastDeviceData.value.dates, lastDeviceData.value.series));
}
if (lastActiveData.value.dates.length) {
updateActiveChart(() => buildActiveChartOptions(lastActiveData.value.dates, lastActiveData.value.series));
}
});
});
onMounted(() => {
loadTodayStats();
loadDeviceDaily();
@@ -243,11 +337,12 @@ onMounted(() => {
<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>新增趋势</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="1">今天</NRadioButton>
<NRadioButton :value="7">近7天</NRadioButton>
<NRadioButton :value="30">近1月</NRadioButton>
<NRadioButton :value="90">近3月</NRadioButton>
@@ -261,11 +356,12 @@ onMounted(() => {
</NGi>
<NGi>
<NCard :bordered="false" class="card-wrapper">
<template #header>设备日活趋势</template>
<template #header>趋势</template>
<template #header-extra>
<NSpace align="center" :size="12">
<NTag type="success" size="small">{{ activeLatest.date.slice(5) }} 总日活 {{ activeLatest.total }}</NTag>
<NTag type="success" size="small">{{ activeLatestLabel }} {{ activeLatest.total }}</NTag>
<NRadioGroup v-model:value="activeDays" size="small">
<NRadioButton :value="1">今天</NRadioButton>
<NRadioButton :value="7">近7天</NRadioButton>
<NRadioButton :value="30">近1月</NRadioButton>
<NRadioButton :value="90">近3月</NRadioButton>