优化首页报表

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
+6
View File
@@ -1,5 +1,11 @@
FROM node:22-alpine
# 统一时区为东八区
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
# Create logs directory
+3
View File
@@ -1,6 +1,9 @@
/**
* 入口文件 — 对应 Python: main.py
*/
// 统一时区为东八区(服务器可能在其他时区,但业务面向中国用户)
process.env.TZ = 'Asia/Shanghai';
require('./config/loadEnv');
const express = require('express');
+80
View File
@@ -73,6 +73,45 @@ 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();
// ===== 今天:按小时分组 =====
if (days === 1) {
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const rows = await UserDevice.findAll({
attributes: [
[fn('HOUR', col('add_time')), 'hour'],
'model',
[fn('COUNT', col('id')), 'count'],
],
where: { add_time: { [Op.gte]: todayStart } },
group: [fn('HOUR', col('add_time')), 'model'],
order: [[fn('HOUR', col('add_time')), 'ASC']],
raw: true,
});
const currentHour = now.getHours();
const hourList = Array.from({ length: currentHour + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`);
const modelSet = new Set();
const dataMap = new Map();
for (const row of rows) {
const hourStr = `${String(parseInt(row.hour, 10)).padStart(2, '0')}:00`;
modelSet.add(row.model);
if (!dataMap.has(hourStr)) dataMap.set(hourStr, {});
dataMap.get(hourStr)[row.model] = parseInt(row.count, 10);
}
const models = [...modelSet].sort();
const series = models.map((m) => ({
name: m,
data: hourList.map((h) => dataMap.get(h)?.[m] || 0),
}));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
return res.json(ApiResponse.success({ dates: hourList, series, total }));
}
// ===== 多天:按日期分组 =====
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1);
// 按日期 + 型号分组统计
@@ -139,6 +178,47 @@ 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();
// ===== 今天:按小时分组(用 create_at 的时间维度)=====
if (days === 1) {
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const rows = await UserActive.findAll({
attributes: [
[fn('HOUR', col('create_at')), 'hour'],
'model',
[fn('COUNT', col('id')), 'count'],
],
where: { create_at: { [Op.gte]: todayStart } },
group: [fn('HOUR', col('create_at')), 'model'],
order: [[fn('HOUR', col('create_at')), 'ASC']],
raw: true,
});
const currentHour = now.getHours();
const hourList = Array.from({ length: currentHour + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`);
const modelSet = new Set();
const dataMap = new Map();
for (const row of rows) {
const hourStr = `${String(parseInt(row.hour, 10)).padStart(2, '0')}:00`;
modelSet.add(row.model);
if (!dataMap.has(hourStr)) dataMap.set(hourStr, {});
dataMap.get(hourStr)[row.model] = parseInt(row.count, 10);
}
const models = [...modelSet].sort();
const series = models.map((m) => ({
name: m,
data: hourList.map((h) => dataMap.get(h)?.[m] || 0),
}));
const todayTotal = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const latest = { date: '今日', total: todayTotal };
return res.json(ApiResponse.success({ dates: hourList, series, latest }));
}
// ===== 多天:按日期分组 =====
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1);
const startDateStr = startDate.toISOString().slice(0, 10);
+78
View File
@@ -7,6 +7,7 @@ const multer = require('multer');
const path = require('path');
const axios = require('axios');
const Model = require('../models/Model');
const Brand = require('../models/Brand');
const logger = require('../config/logger');
const { ApiResponse } = require('../utils/response');
const { authMiddleware } = require('../middleware/auth');
@@ -580,6 +581,83 @@ router.delete('/api/models/:model_id', async (req, res) => {
}
});
// PATCH /api/models/batch-update — 批量修改型号字段
router.patch('/api/models/batch-update', async (req, res) => {
try {
const { ids, brand_name, form, source, rig } = req.body || {};
if (!Array.isArray(ids) || !ids.length) {
return res.json(ApiResponse.error('请选择要修改的型号'));
}
// 至少指定一个要修改的字段
const hasUpdate = [brand_name, form, source, rig].some(
(v) => v !== undefined && v !== null
);
if (!hasUpdate) {
return res.json(ApiResponse.error('请至少指定一个要修改的字段'));
}
// 若修改品牌名称,确保品牌存在
if (brand_name) {
const bn = String(brand_name).trim();
const existing = await Brand.findOne({ where: { name: bn } });
if (!existing) {
await Brand.create({ name: bn });
logger.info(`Brand created during batch update: ${bn}`);
}
}
const models = await Model.findAll({ where: { id: { [Op.in]: ids } } });
if (!models.length) {
return res.json(ApiResponse.error('未找到选中的型号数据'));
}
const updatedIds = [];
const errors = [];
for (const m of models) {
try {
const oldParams = {
source: m.source,
form: m.form,
brandName: m.brand_name,
modelName: m.name,
};
if (brand_name !== undefined && brand_name !== null) m.brand_name = String(brand_name).trim();
if (form !== undefined && form !== null) m.form = form || null;
if (source !== undefined && source !== null) m.source = source || null;
if (rig !== undefined && rig !== null) m.rig = rig || null;
// S3 CSV 路径迁移(brand_name 或 form 变更时)
const newParams = {
source: m.source,
form: m.form,
brandName: m.brand_name,
modelName: m.name,
};
await moveMeasurementOnS3(oldParams, newParams);
await m.save();
updatedIds.push(m.id);
} catch (e) {
logger.error(`Batch update failed for model id=${m.id}: ${e.message}`);
errors.push({ id: m.id, brand_name: m.brand_name, name: m.name, error: e.message });
}
}
logger.info(`Batch update: ${updatedIds.length} succeeded, ${errors.length} failed`);
return res.json(ApiResponse.success({
updated_count: updatedIds.length,
updated_ids: updatedIds,
errors,
}));
} catch (e) {
logger.error(`Batch update error: ${e.message}`);
return res.json(ApiResponse.error('error'));
}
});
// POST /api/models/push-to-search/validate
router.post('/api/models/push-to-search/validate', async (req, res) => {
try {