首页新增新增&日活报表

This commit is contained in:
eafonyang
2026-07-20 18:39:33 +08:00
parent d2a8ef2f2d
commit 3a0108dc54
26 changed files with 990 additions and 613 deletions
+42
View File
@@ -0,0 +1,42 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const UserActive = sequelize.define('UserActive', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '自增主键',
},
mac_addr: {
type: DataTypes.STRING(30),
allowNull: false,
comment: '设备 MAC 地址',
},
model: {
type: DataTypes.STRING(50),
allowNull: false,
comment: '设备型号(如 Luxsin-X9、Luxsin-X8',
},
active_date: {
type: DataTypes.DATEONLY,
allowNull: false,
comment: '活跃日期',
},
ip_addr: {
type: DataTypes.STRING(45),
allowNull: false,
comment: '活跃时 IP 地址',
},
create_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: '记录创建时间',
},
}, {
tableName: 'user_active',
comment: '用户设备日活记录(mac_addr + active_date 唯一)',
});
module.exports = UserActive;
+38
View File
@@ -0,0 +1,38 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const UserDevice = sequelize.define('UserDevice', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '自增主键',
},
mac_addr: {
type: DataTypes.STRING(30),
allowNull: false,
unique: true,
comment: '设备 MAC 地址(唯一)',
},
model: {
type: DataTypes.STRING(50),
allowNull: false,
comment: '设备型号(如 Luxsin-X9、Luxsin-X8',
},
add_time: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: '首次注册时间',
},
ver: {
type: DataTypes.STRING(10),
allowNull: true,
comment: '固件版本号',
},
}, {
tableName: 'user_device',
comment: '用户设备注册记录',
});
module.exports = UserDevice;
+140 -1
View File
@@ -2,9 +2,11 @@
* Dashboard 路由 — 首页统计数据
*/
const router = require('express').Router();
const { Op } = require('sequelize');
const { Op, fn, col } = require('sequelize');
const Model = require('../models/Model');
const Ota = require('../models/Ota');
const UserDevice = require('../models/UserDevice');
const UserActive = require('../models/UserActive');
const logger = require('../config/logger');
const { ApiResponse } = require('../utils/response');
const { authMiddleware } = require('../middleware/auth');
@@ -62,4 +64,141 @@ router.get('/api/dashboard/today', async (req, res) => {
}
});
/**
* GET /api/dashboard/device-daily?days=30
* 返回最近 N 天每天各设备型号的新增数量(堆叠柱状图数据)
* 返回格式:{ dates: ['2026-06-16', ...], series: [{ name: 'Luxsin-X9', data: [5, 3, ...] }, ...], total: 123 }
*/
router.get('/api/dashboard/device-daily', async (req, res) => {
try {
const days = Math.min(Math.max(parseInt(req.query.days || '30', 10) || 30, 1), 365);
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1);
// 按日期 + 型号分组统计
const rows = await UserDevice.findAll({
attributes: [
[fn('DATE', col('add_time')), 'date'],
'model',
[fn('COUNT', col('id')), 'count'],
],
where: {
add_time: { [Op.gte]: startDate },
},
group: [fn('DATE', col('add_time')), 'model'],
order: [[fn('DATE', col('add_time')), 'ASC']],
raw: true,
});
// 构建日期序列(补全没有数据的日期)
const dateList = [];
const tmp = new Date(startDate);
while (tmp <= now) {
dateList.push(tmp.toISOString().slice(0, 10));
tmp.setDate(tmp.getDate() + 1);
}
// 收集所有型号
const modelSet = new Set();
const dataMap = new Map(); // date -> { model -> count }
for (const row of rows) {
const dateStr = row.date instanceof Date ? row.date.toISOString().slice(0, 10) : String(row.date).slice(0, 10);
modelSet.add(row.model);
if (!dataMap.has(dateStr)) dataMap.set(dateStr, {});
dataMap.get(dateStr)[row.model] = parseInt(row.count, 10);
}
// 构建系列数据
const models = [...modelSet].sort();
const series = models.map((m) => ({
name: m,
data: dateList.map((d) => dataMap.get(d)?.[m] || 0),
}));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
return res.json(
ApiResponse.success({
dates: dateList,
series,
total,
})
);
} catch (e) {
logger.error(`Error getting device daily stats: ${e.message}`);
return res.json(ApiResponse.error(`获取设备统计数据失败:${e.message}`));
}
});
/**
* GET /api/dashboard/active-daily?days=30
* 返回最近 N 天每天各设备型号的日活数量(折线图数据)
* 返回格式:{ dates: ['2026-06-16', ...], series: [{ name: 'Luxsin-X9', data: [5, 3, ...] }, ...], latest: { date, total } }
*/
router.get('/api/dashboard/active-daily', async (req, res) => {
try {
const days = Math.min(Math.max(parseInt(req.query.days || '30', 10) || 30, 1), 365);
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1);
const startDateStr = startDate.toISOString().slice(0, 10);
// 按活跃日期 + 型号分组统计(active_date 为 DATE 类型,唯一键保证每设备每天一条)
const rows = await UserActive.findAll({
attributes: [
'active_date',
'model',
[fn('COUNT', col('id')), 'count'],
],
where: {
active_date: { [Op.gte]: startDateStr },
},
group: ['active_date', 'model'],
order: [['active_date', 'ASC']],
raw: true,
});
// 构建日期序列(补全没有数据的日期)
const dateList = [];
const tmp = new Date(startDate);
while (tmp <= now) {
dateList.push(tmp.toISOString().slice(0, 10));
tmp.setDate(tmp.getDate() + 1);
}
// 收集所有型号
const modelSet = new Set();
const dataMap = new Map(); // date -> { model -> count }
for (const row of rows) {
const dateStr = String(row.active_date).slice(0, 10);
modelSet.add(row.model);
if (!dataMap.has(dateStr)) dataMap.set(dateStr, {});
dataMap.get(dateStr)[row.model] = parseInt(row.count, 10);
}
// 构建系列数据
const models = [...modelSet].sort();
const series = models.map((m) => ({
name: m,
data: dateList.map((d) => dataMap.get(d)?.[m] || 0),
}));
// 计算每天总日活,取最近一个有数据的日子作为「最新一天总日活」
const dailyTotals = dateList.map((d) => models.reduce((sum, m) => sum + (dataMap.get(d)?.[m] || 0), 0));
let latestIdx = dailyTotals.length - 1;
while (latestIdx > 0 && dailyTotals[latestIdx] === 0) latestIdx--;
const latest = { date: dateList[latestIdx], total: dailyTotals[latestIdx] };
return res.json(
ApiResponse.success({
dates: dateList,
series,
latest,
})
);
} catch (e) {
logger.error(`Error getting active daily stats: ${e.message}`);
return res.json(ApiResponse.error(`获取设备日活统计数据失败:${e.message}`));
}
});
module.exports = router;