MES/ERP的Web多页签报表系统

MES/ERP 报表系统(Node.js + 本地数据库版)

针对 MES/ERP 报表场景的规则条件约束、高索引查询、组轴分栏报表、多 Tab 界面展示 核心需求,我基于 Node.js + SQLite(本地文件数据库)实现一套完整的报表系统,包含查询引擎、规则管理、多维度报表、可视化展示四大核心模块,适配本地化部署且支持 Tab 标签式多界面交互。

一、项目整体设计

1. 技术栈选型

表格

技术层面 选型 优势说明
后端核心 Node.js (Express) 轻量高效,异步 IO 适配数据查询场景
本地数据库 SQLite3 文件存储无需服务,适配 MES/ERP 本地化需求
查询优化 sqlite3 + better-sqlite3 高索引查询封装,支持预编译 SQL / 事务
前端界面 HTML + Vue2 + Element UI 丰富的 Tab 标签 / 分栏报表控件,易上手
可视化 ECharts 支持组轴分栏报表、趋势分析、多维度图表
打包部署 Electron 打包为桌面应用,支持 Windows/Linux/macOS

2. 系统架构

复制代码
graph TD
    A[Electron主进程<br/>窗口/Tab管理] --> B[Express后端服务<br/>API接口/权限控制]
    B --> C[查询引擎层<br/>规则解析/高索引查询封装]
    C --> D[数据访问层<br/>SQLite操作/索引管理]
    D --> E[本地文件存储<br/>SQLite数据库文件]
    
    A --> F[Vue前端渲染层<br/>Tab标签/分栏报表/可视化]
    C --> G[规则管理层<br/>条件约束/组轴配置]
    C --> H[数据分析层<br/>聚合/对比/趋势计算]

Electron主进程

窗口/Tab管理

Express后端服务

API接口/权限控制

查询引擎层

规则解析/高索引查询封装

数据访问层

SQLite操作/索引管理

本地文件存储

SQLite数据库文件

Vue前端渲染层

Tab标签/分栏报表/可视化

规则管理层

条件约束/组轴配置

数据分析层

聚合/对比/趋势计算

3. 核心功能模块

表格

模块名称 核心功能
规则配置模块 报表查询规则定义(条件约束、索引关联、组轴维度)
高索引查询模块 预编译 SQL、复合索引封装、分页 / 缓存优化
组轴分栏报表模块 产线 / 产品 / 时间多轴分栏展示、自定义列布局、数据钻取
多 Tab 展示模块 支持多报表同时打开、Tab 标签切换 / 关闭 / 保存、界面状态记忆
数据分析模块 数据聚合、同比 / 环比、异常标记、导出 Excel/PDF

二、完整项目实现

第一步:项目初始化与依赖安装

1. 项目目录结构

plaintext

复制代码
mes-erp-report/
├── main/                  # Electron主进程
│   ├── index.js           # 窗口/Tab管理
│   └── preload.js         # 渲染进程通信
├── server/                # Express后端
│   ├── api/               # API接口
│   │   ├── report.js      # 报表查询接口
│   │   └── rule.js        # 规则配置接口
│   ├── db/                # 数据库操作
│   │   ├── index.js       # SQLite连接/索引封装
│   │   └── init.js        # 建表/测试数据/索引初始化
│   ├── engine/            # 查询引擎
│   │   ├── query.js       # 高索引查询封装
│   │   └── rule.js        # 规则解析
│   └── app.js             # Express入口
├── client/                # 前端页面
│   ├── index.html         # 主页面(Tab容器)
│   ├── js/
│   │   ├── main.js        # Vue入口
│   │   └── report.js      # 报表组件
│   ├── css/               # 样式
│   └── lib/               # 第三方库(Vue/ECharts/ElementUI)
├── package.json           # 依赖配置
└── sqlite.db              # 本地数据库文件(自动生成)
2. package.json 配置

json

复制代码
{
  "name": "mes-erp-report",
  "version": "1.0.0",
  "main": "main/index.js",
  "scripts": {
    "start": "electron .",
    "dev": "nodemon --exec electron ."
  },
  "dependencies": {
    "better-sqlite3": "^9.4.0",
    "express": "^4.18.2",
    "electron": "^28.2.0",
    "sqlite3": "^5.1.6",
    "cors": "^2.8.5",
    "xlsx": "^0.18.5",
    "echarts": "^5.4.3",
    "vue": "^2.7.14",
    "element-ui": "^2.15.14"
  },
  "devDependencies": {
    "nodemon": "^3.0.2"
  }
}

第二步:后端核心实现

1. 数据库初始化与高索引封装(server/db/index.js)

javascript

运行

复制代码
const Database = require('better-sqlite3');
const path = require('path');

// 本地数据库路径(项目根目录/sqlite.db)
const dbPath = path.resolve(__dirname, '../../sqlite.db');
const db = new Database(dbPath, { verbose: console.log });

// 初始化数据库(建表+核心索引)
function initDB() {
  // 1. 产线表
  db.exec(`
    CREATE TABLE IF NOT EXISTS production_line (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      line_code TEXT NOT NULL UNIQUE,
      line_name TEXT NOT NULL,
      workshop TEXT,
      status TEXT DEFAULT 'Running'
    );
  `);

  // 2. 产品表
  db.exec(`
    CREATE TABLE IF NOT EXISTS product (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      product_code TEXT NOT NULL UNIQUE,
      product_name TEXT NOT NULL,
      spec TEXT,
      unit_price DECIMAL(10,2) DEFAULT 0
    );
  `);

  // 3. 工单生产数据表(核心报表表)
  db.exec(`
    CREATE TABLE IF NOT EXISTS work_order (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      work_order_no TEXT NOT NULL,
      line_code TEXT NOT NULL,
      product_code TEXT NOT NULL,
      production_date DATETIME NOT NULL,
      plan_qty INTEGER DEFAULT 0,
      actual_qty INTEGER DEFAULT 0,
      defect_qty INTEGER DEFAULT 0,
      oee DECIMAL(5,2) DEFAULT 0,
      shift TEXT
    );
  `);

  // 4. 报表规则表
  db.exec(`
    CREATE TABLE IF NOT EXISTS report_rule (
      rule_id INTEGER PRIMARY KEY AUTOINCREMENT,
      rule_name TEXT NOT NULL,
      report_type TEXT NOT NULL,
      conditions TEXT,
      index_fields TEXT,
      sort_field TEXT,
      is_asc INTEGER DEFAULT 1,
      group_field TEXT
    );
  `);

  // 核心索引创建(高索引优化)
  db.exec(`
    -- 工单数据复合索引(产线+日期:最常用查询维度)
    CREATE INDEX IF NOT EXISTS IX_work_order_line_date ON work_order(line_code, production_date);
    -- 工单数据复合索引(产品+日期:产品报表维度)
    CREATE INDEX IF NOT EXISTS IX_work_order_product_date ON work_order(product_code, production_date);
    -- 工单号索引(精准查询)
    CREATE INDEX IF NOT EXISTS IX_work_order_no ON work_order(work_order_no);
  `);

  // 插入测试数据
  insertTestData();
}

// 插入测试数据
function insertTestData() {
  // 检查产线数据
  const lineCount = db.prepare('SELECT COUNT(*) FROM production_line').get()['COUNT(*)'];
  if (lineCount === 0) {
    const insertLine = db.prepare(`
      INSERT INTO production_line (line_code, line_name, workshop)
      VALUES (@line_code, @line_name, @workshop)
    `);
    insertLine.run({ line_code: 'LINE01', line_name: '一号产线', workshop: '组装车间' });
    insertLine.run({ line_code: 'LINE02', line_name: '二号产线', workshop: '组装车间' });
    insertLine.run({ line_code: 'LINE03', line_name: '三号产线', workshop: '包装车间' });
  }

  // 检查产品数据
  const productCount = db.prepare('SELECT COUNT(*) FROM product').get()['COUNT(*)'];
  if (productCount === 0) {
    const insertProduct = db.prepare(`
      INSERT INTO product (product_code, product_name, spec, unit_price)
      VALUES (@product_code, @product_name, @spec, @unit_price)
    `);
    insertProduct.run({ product_code: 'PROD001', product_name: '产品A', spec: '规格A-1', unit_price: 100.50 });
    insertProduct.run({ product_code: 'PROD002', product_name: '产品B', spec: '规格B-2', unit_price: 200.80 });
    insertProduct.run({ product_code: 'PROD003', product_name: '产品C', spec: '规格C-3', unit_price: 150.20 });
  }

  // 检查工单数据
  const orderCount = db.prepare('SELECT COUNT(*) FROM work_order').get()['COUNT(*)'];
  if (orderCount === 0) {
    const insertOrder = db.prepare(`
      INSERT INTO work_order (work_order_no, line_code, product_code, production_date, plan_qty, actual_qty, defect_qty, oee, shift)
      VALUES (@work_order_no, @line_code, @product_code, @production_date, @plan_qty, @actual_qty, @defect_qty, @oee, @shift)
    `);
    const startDate = new Date();
    startDate.setDate(startDate.getDate() - 7);
    
    for (let i = 0; i < 7; i++) {
      const date = new Date(startDate);
      date.setDate(date.getDate() + i);
      const dateStr = date.toISOString().split('T')[0];
      
      insertOrder.run({
        work_order_no: `WO${dateStr.replace(/-/g, '')}001`,
        line_code: 'LINE01',
        product_code: 'PROD001',
        production_date: dateStr,
        plan_qty: 1000,
        actual_qty: 950 + i * 10,
        defect_qty: 10 + i,
        oee: 85.5 + i,
        shift: '白班'
      });
      
      insertOrder.run({
        work_order_no: `WO${dateStr.replace(/-/g, '')}002`,
        line_code: 'LINE01',
        product_code: 'PROD002',
        production_date: dateStr,
        plan_qty: 800,
        actual_qty: 780 + i * 5,
        defect_qty: 8 + i,
        oee: 88.2 + i,
        shift: '白班'
      });
    }
  }
}

// 高索引查询封装(核心方法)
class DBQuery {
  // 分页查询(带索引优化)
  queryWithPage(sql, params = {}, page = 1, pageSize = 20) {
    const offset = (page - 1) * pageSize;
    // 预编译SQL(提升执行效率)
    const countStmt = db.prepare(`SELECT COUNT(*) FROM (${sql}) AS t`);
    const dataStmt = db.prepare(`${sql} LIMIT ? OFFSET ?`);
    
    const total = countStmt.get(params)['COUNT(*)'];
    const data = dataStmt.all(params, pageSize, offset);
    
    return {
      data,
      pagination: {
        total,
        page,
        pageSize,
        pages: Math.ceil(total / pageSize)
      }
    };
  }

  // 执行SQL(带事务)
  executeWithTransaction(sqls, paramsList) {
    const transaction = db.transaction((sqls, paramsList) => {
      for (let i = 0; i < sqls.length; i++) {
        db.prepare(sqls[i]).run(paramsList[i] || {});
      }
    });
    return transaction(sqls, paramsList);
  }

  // 获取索引信息
  getIndexInfo(tableName) {
    const stmt = db.prepare(`
      SELECT name AS index_name, sql 
      FROM sqlite_master 
      WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'
    `);
    return stmt.all(tableName);
  }
}

// 初始化数据库
initDB();

module.exports = {
  db,
  DBQuery,
  initDB
};
2. 查询引擎(高索引 + 规则解析)(server/engine/query.js)

javascript

运行

复制代码
const { DBQuery } = require('../db/index');
const queryHelper = new DBQuery();

// 报表查询引擎(核心:规则解析+高索引查询)
class ReportQueryEngine {
  // 产线日报表查询(高索引优化)
  getLineDailyReport(params) {
    const { startDate, endDate, lineCodes = [] } = params;
    let sql = `
      SELECT 
        w.line_code,
        l.line_name,
        w.production_date,
        SUM(w.plan_qty) AS total_plan_qty,
        SUM(w.actual_qty) AS total_actual_qty,
        SUM(w.defect_qty) AS total_defect_qty,
        AVG(w.oee) AS avg_oee,
        ROUND(SUM(w.actual_qty)*100.0/IFNULL(SUM(w.plan_qty),1), 2) AS completion_rate,
        ROUND(SUM(w.defect_qty)*100.0/IFNULL(SUM(w.actual_qty),1), 2) AS defect_rate
      FROM work_order w
      LEFT JOIN production_line l ON w.line_code = l.line_code
      WHERE w.production_date BETWEEN ? AND ?
    `;
    
    const sqlParams = [startDate, endDate];
    
    // 产线过滤(使用索引字段)
    if (lineCodes.length > 0) {
      sql += ` AND w.line_code IN (${lineCodes.map((_, i) => `?`).join(',')})`;
      sqlParams.push(...lineCodes);
    }
    
    sql += `
      GROUP BY w.line_code, w.production_date, l.line_name
      ORDER BY w.production_date DESC, w.line_code
    `;
    
    // 分页查询(高索引优化:使用line_code+production_date复合索引)
    return queryHelper.queryWithPage(sql, sqlParams, params.page, params.pageSize);
  }

  // 组轴分栏报表查询(产线+产品+时间三轴)
  getAxisReport(params) {
    const { lineCode, productCode, startDate, endDate, groupAxis = 'line_code' } = params;
    let sql = `
      SELECT 
        w.line_code,
        l.line_name,
        w.product_code,
        p.product_name,
        w.production_date,
        w.shift,
        SUM(w.plan_qty) AS plan_qty,
        SUM(w.actual_qty) AS actual_qty,
        SUM(w.defect_qty) AS defect_qty,
        AVG(w.oee) AS oee,
        ROUND(SUM(w.actual_qty)*100.0/IFNULL(SUM(w.plan_qty),1), 2) AS completion_rate
      FROM work_order w
      LEFT JOIN production_line l ON w.line_code = l.line_code
      LEFT JOIN product p ON w.product_code = p.product_code
      WHERE 1=1
    `;
    
    const sqlParams = [];
    
    // 条件过滤(均使用索引字段)
    if (startDate) {
      sql += ` AND w.production_date >= ?`;
      sqlParams.push(startDate);
    }
    if (endDate) {
      sql += ` AND w.production_date <= ?`;
      sqlParams.push(endDate);
    }
    if (lineCode) {
      sql += ` AND w.line_code = ?`;
      sqlParams.push(lineCode);
    }
    if (productCode) {
      sql += ` AND w.product_code = ?`;
      sqlParams.push(productCode);
    }
    
    // 组轴分组
    sql += `
      GROUP BY w.${groupAxis}, w.production_date, w.shift, l.line_name, p.product_name
      ORDER BY w.production_date DESC, w.${groupAxis}
    `;
    
    // 分页查询
    return queryHelper.queryWithPage(sql, sqlParams, params.page, params.pageSize);
  }

  // 按规则查询报表
  queryByRule(rule, params) {
    const { conditions, index_fields, sort_field, is_asc, group_field } = rule;
    let sql = `SELECT ${index_fields || '*'} FROM work_order WHERE 1=1`;
    const sqlParams = [];
    
    // 解析规则条件
    if (conditions) {
      try {
        const conditionList = JSON.parse(conditions);
        conditionList.forEach(cond => {
          if (cond.value) {
            sql += ` AND ${cond.field_name} ${cond.operator} ?`;
            sqlParams.push(cond.value);
          }
        });
      } catch (e) {
        console.error('规则条件解析失败:', e);
      }
    }
    
    // 分组
    if (group_field) {
      sql += ` GROUP BY ${group_field}`;
    }
    
    // 排序(使用索引字段)
    if (sort_field) {
      sql += ` ORDER BY ${sort_field} ${is_asc ? 'ASC' : 'DESC'}`;
    }
    
    // 分页查询
    return queryHelper.queryWithPage(sql, sqlParams, params.page, params.pageSize);
  }
}

module.exports = new ReportQueryEngine();
3. Express API 接口(server/app.js)

javascript

运行

复制代码
const express = require('express');
const cors = require('cors');
const path = require('path');
const reportEngine = require('./engine/query');
const { db } = require('./db/index');

const app = express();
const PORT = 3000;

// 中间件
app.use(cors());
app.use(express.json());
app.use(express.static(path.resolve(__dirname, '../client')));

// 1. 产线日报表接口
app.post('/api/report/line-daily', (req, res) => {
  try {
    const result = reportEngine.getLineDailyReport(req.body);
    res.json({
      code: 200,
      data: result,
      message: '查询成功'
    });
  } catch (e) {
    res.json({
      code: 500,
      data: null,
      message: `查询失败:${e.message}`
    });
  }
});

// 2. 组轴分栏报表接口
app.post('/api/report/axis', (req, res) => {
  try {
    const result = reportEngine.getAxisReport(req.body);
    res.json({
      code: 200,
      data: result,
      message: '查询成功'
    });
  } catch (e) {
    res.json({
      code: 500,
      data: null,
      message: `查询失败:${e.message}`
    });
  }
});

// 3. 规则配置接口
app.get('/api/rule/list', (req, res) => {
  try {
    const stmt = db.prepare('SELECT * FROM report_rule');
    const rules = stmt.all();
    res.json({
      code: 200,
      data: rules,
      message: '查询成功'
    });
  } catch (e) {
    res.json({
      code: 500,
      data: null,
      message: `查询失败:${e.message}`
    });
  }
});

// 4. 保存规则接口
app.post('/api/rule/save', (req, res) => {
  try {
    const { rule_id, rule_name, report_type, conditions, index_fields, sort_field, is_asc, group_field } = req.body;
    let stmt;
    
    if (rule_id) {
      // 更新
      stmt = db.prepare(`
        UPDATE report_rule
        SET rule_name = ?, report_type = ?, conditions = ?, index_fields = ?, sort_field = ?, is_asc = ?, group_field = ?
        WHERE rule_id = ?
      `);
      stmt.run(rule_name, report_type, JSON.stringify(conditions), index_fields, sort_field, is_asc ? 1 : 0, group_field, rule_id);
    } else {
      // 新增
      stmt = db.prepare(`
        INSERT INTO report_rule (rule_name, report_type, conditions, index_fields, sort_field, is_asc, group_field)
        VALUES (?, ?, ?, ?, ?, ?, ?)
      `);
      stmt.run(rule_name, report_type, JSON.stringify(conditions), index_fields, sort_field, is_asc ? 1 : 0, group_field);
    }
    
    res.json({
      code: 200,
      data: null,
      message: '保存成功'
    });
  } catch (e) {
    res.json({
      code: 500,
      data: null,
      message: `保存失败:${e.message}`
    });
  }
});

// 5. 基础数据接口(产线/产品)
app.get('/api/base/lines', (req, res) => {
  try {
    const stmt = db.prepare('SELECT * FROM production_line');
    const lines = stmt.all();
    res.json({
      code: 200,
      data: lines,
      message: '查询成功'
    });
  } catch (e) {
    res.json({
      code: 500,
      data: null,
      message: `查询失败:${e.message}`
    });
  }
});

app.get('/api/base/products', (req, res) => {
  try {
    const stmt = db.prepare('SELECT * FROM product');
    const products = stmt.all();
    res.json({
      code: 200,
      data: products,
      message: '查询成功'
    });
  } catch (e) {
    res.json({
      code: 500,
      data: null,
      message: `查询失败:${e.message}`
    });
  }
});

// 启动服务
app.listen(PORT, () => {
  console.log(`Express服务启动:http://localhost:${PORT}`);
});

module.exports = app;

第三步:前端界面实现(Vue + Element UI)

1. 主页面(Tab 标签容器)(client/index.html)
复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>MES/ERP报表管理系统</title>
  <!-- Element UI样式 -->
  <link rel="stylesheet" href="lib/element-ui/theme-chalk/index.css">
  <!-- 自定义样式 -->
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: "Microsoft YaHei", sans-serif;
    }
    .main-container {
      padding: 10px;
      height: 100vh;
      display: flex;
      flex-direction: column;
    }
    .el-tabs {
      flex: 1;
      display: flex;
      flex-direction: column;
    }
    .el-tabs__content {
      flex: 1;
      padding: 10px 0;
      overflow: auto;
    }
    .report-card {
      height: 100%;
      display: flex;
      flex-direction: column;
    }
    .query-form {
      margin-bottom: 10px;
      padding: 10px;
      background: #f5f5f5;
      border-radius: 4px;
    }
    .chart-container {
      display: flex;
      gap: 10px;
      margin: 10px 0;
      height: 300px;
    }
    .chart-item {
      flex: 1;
      border: 1px solid #e6e6e6;
      border-radius: 4px;
      padding: 10px;
    }
  </style>
</head>
<body>
  <div id="app" class="main-container">
    <el-button type="primary" @click="addTab('lineDaily', '产线日报表')">打开产线日报表</el-button>
    <el-button type="primary" @click="addTab('axisReport', '组轴分栏报表')">打开组轴分栏报表</el-button>
    
    <el-tabs v-model="activeTab" type="card" closable @tab-remove="removeTab">
      <el-tab-pane v-for="tab in tabs" :key="tab.name" :label="tab.label" :name="tab.name">
        <!-- 产线日报表 -->
        <div v-if="tab.name === 'lineDaily'" class="report-card">
          <div class="query-form">
            <el-form :inline="true" :model="lineQueryForm" class="demo-form-inline">
              <el-form-item label="开始日期">
                <el-date-picker v-model="lineQueryForm.startDate" type="date" placeholder="选择开始日期"></el-date-picker>
              </el-form-item>
              <el-form-item label="结束日期">
                <el-date-picker v-model="lineQueryForm.endDate" type="date" placeholder="选择结束日期"></el-date-picker>
              </el-form-item>
              <el-form-item label="产线">
                <el-select v-model="lineQueryForm.lineCodes" multiple placeholder="选择产线">
                  <el-option v-for="line in lines" :key="line.line_code" :label="line.line_name" :value="line.line_code"></el-option>
                </el-select>
              </el-form-item>
              <el-form-item>
                <el-button type="primary" @click="queryLineDaily">查询</el-button>
              </el-form-item>
            </el-form>
          </div>
          
          <!-- 数据表格 -->
          <el-table :data="lineTableData" border style="width: 100%;">
            <el-table-column prop="line_code" label="产线编码"></el-table-column>
            <el-table-column prop="line_name" label="产线名称"></el-table-column>
            <el-table-column prop="production_date" label="生产日期"></el-table-column>
            <el-table-column prop="total_plan_qty" label="计划产量"></el-table-column>
            <el-table-column prop="total_actual_qty" label="实际产量"></el-table-column>
            <el-table-column prop="completion_rate" label="完成率(%)"></el-table-column>
            <el-table-column prop="total_defect_qty" label="不良数量"></el-table-column>
            <el-table-column prop="defect_rate" label="不良率(%)"></el-table-column>
            <el-table-column prop="avg_oee" label="平均OEE"></el-table-column>
          </el-table>
          
          <!-- 分页 -->
          <el-pagination
            @size-change="handleLineSizeChange"
            @current-change="handleLineCurrentChange"
            :current-page="lineQueryForm.page"
            :page-sizes="[10, 20, 50, 100]"
            :page-size="lineQueryForm.pageSize"
            layout="total, sizes, prev, pager, next, jumper"
            :total="lineTotal">
          </el-pagination>
          
          <!-- 图表展示 -->
          <div class="chart-container">
            <div class="chart-item" id="lineProductionChart"></div>
            <div class="chart-item" id="lineRateChart"></div>
          </div>
        </div>
        
        <!-- 组轴分栏报表 -->
        <div v-if="tab.name === 'axisReport'" class="report-card">
          <div class="query-form">
            <el-form :inline="true" :model="axisQueryForm" class="demo-form-inline">
              <el-form-item label="开始日期">
                <el-date-picker v-model="axisQueryForm.startDate" type="date" placeholder="选择开始日期"></el-date-picker>
              </el-form-item>
              <el-form-item label="结束日期">
                <el-date-picker v-model="axisQueryForm.endDate" type="date" placeholder="选择结束日期"></el-date-picker>
              </el-form-item>
              <el-form-item label="分组轴">
                <el-select v-model="axisQueryForm.groupAxis" placeholder="选择分组轴">
                  <el-option label="产线" value="line_code"></el-option>
                  <el-option label="产品" value="product_code"></el-option>
                  <el-option label="班次" value="shift"></el-option>
                </el-select>
              </el-form-item>
              <el-form-item>
                <el-button type="primary" @click="queryAxisReport">查询</el-button>
              </el-form-item>
            </el-form>
          </div>
          
          <!-- 分栏表格 -->
          <el-table :data="axisTableData" border style="width: 100%;">
            <el-table-column prop="line_code" label="产线编码"></el-table-column>
            <el-table-column prop="line_name" label="产线名称"></el-table-column>
            <el-table-column prop="product_code" label="产品编码"></el-table-column>
            <el-table-column prop="product_name" label="产品名称"></el-table-column>
            <el-table-column prop="production_date" label="生产日期"></el-table-column>
            <el-table-column prop="shift" label="班次"></el-table-column>
            <el-table-column prop="actual_qty" label="实际产量"></el-table-column>
            <el-table-column prop="completion_rate" label="完成率(%)"></el-table-column>
            <el-table-column prop="oee" label="OEE"></el-table-column>
          </el-table>
          
          <!-- 分页 -->
          <el-pagination
            @size-change="handleAxisSizeChange"
            @current-change="handleAxisCurrentChange"
            :current-page="axisQueryForm.page"
            :page-sizes="[10, 20, 50, 100]"
            :page-size="axisQueryForm.pageSize"
            layout="total, sizes, prev, pager, next, jumper"
            :total="axisTotal">
          </el-pagination>
          
          <!-- 组轴图表 -->
          <div class="chart-container">
            <div class="chart-item" id="axisProductionChart"></div>
          </div>
        </div>
      </el-tab-pane>
    </el-tabs>
  </div>

  <!-- 第三方库 -->
  <script src="lib/vue/vue.min.js"></script>
  <script src="lib/element-ui/index.js"></script>
  <script src="lib/echarts/echarts.min.js"></script>
  <script src="lib/axios/axios.min.js"></script>
  
  <!-- 主脚本 -->
  <script src="js/main.js"></script>
</body>
</html>
2. Vue 逻辑(client/js/main.js)

javascript

运行

复制代码
// 初始化Vue实例
new Vue({
  el: '#app',
  data() {
    return {
      // Tab标签
      tabs: [],
      activeTab: '',
      
      // 产线列表
      lines: [],
      
      // 产线日报表查询条件
      lineQueryForm: {
        startDate: '',
        endDate: '',
        lineCodes: [],
        page: 1,
        pageSize: 20
      },
      lineTableData: [],
      lineTotal: 0,
      
      // 组轴分栏报表查询条件
      axisQueryForm: {
        startDate: '',
        endDate: '',
        lineCode: '',
        productCode: '',
        groupAxis: 'line_code',
        page: 1,
        pageSize: 20
      },
      axisTableData: [],
      axisTotal: 0,
      
      // 图表实例
      lineProductionChart: null,
      lineRateChart: null,
      axisProductionChart: null
    };
  },
  created() {
    // 初始化日期
    const endDate = new Date();
    const startDate = new Date();
    startDate.setDate(endDate.getDate() - 7);
    
    this.lineQueryForm.startDate = startDate.toISOString().split('T')[0];
    this.lineQueryForm.endDate = endDate.toISOString().split('T')[0];
    
    this.axisQueryForm.startDate = startDate.toISOString().split('T')[0];
    this.axisQueryForm.endDate = endDate.toISOString().split('T')[0];
    
    // 加载基础数据
    this.loadBaseData();
    
    // 初始化图表
    this.initCharts();
  },
  methods: {
    // 初始化图表
    initCharts() {
      this.lineProductionChart = echarts.init(document.getElementById('lineProductionChart'));
      this.lineRateChart = echarts.init(document.getElementById('lineRateChart'));
      this.axisProductionChart = echarts.init(document.getElementById('axisProductionChart'));
    },
    
    // 加载基础数据
    async loadBaseData() {
      try {
        const res = await axios.get('/api/base/lines');
        if (res.data.code === 200) {
          this.lines = res.data.data;
        }
      } catch (e) {
        this.$message.error('加载基础数据失败');
        console.error(e);
      }
    },
    
    // 添加Tab标签
    addTab(name, label) {
      // 检查是否已存在
      const exists = this.tabs.some(tab => tab.name === name);
      if (!exists) {
        this.tabs.push({ name, label });
      }
      this.activeTab = name;
      
      // 自动查询数据
      if (name === 'lineDaily') {
        this.queryLineDaily();
      } else if (name === 'axisReport') {
        this.queryAxisReport();
      }
    },
    
    // 移除Tab标签
    removeTab(targetName) {
      let activeName = this.activeTab;
      if (activeName === targetName) {
        this.tabs.forEach((tab, index) => {
          if (tab.name === targetName) {
            const nextTab = this.tabs[index + 1] || this.tabs[index - 1];
            if (nextTab) {
              activeName = nextTab.name;
            }
          }
        });
      }
      
      this.tabs = this.tabs.filter(tab => tab.name !== targetName);
      this.activeTab = activeName;
    },
    
    // 查询产线日报表
    async queryLineDaily() {
      try {
        const res = await axios.post('/api/report/line-daily', this.lineQueryForm);
        if (res.data.code === 200) {
          this.lineTableData = res.data.data.data;
          this.lineTotal = res.data.data.pagination.total;
          
          // 更新图表
          this.updateLineCharts();
        } else {
          this.$message.error(res.data.message);
        }
      } catch (e) {
        this.$message.error('查询失败');
        console.error(e);
      }
    },
    
    // 更新产线报表图表
    updateLineCharts() {
      // 产量趋势图
      const dateSet = new Set();
      const lineSet = new Set();
      this.lineTableData.forEach(item => {
        dateSet.add(item.production_date);
        lineSet.add(item.line_code);
      });
      
      const dates = Array.from(dateSet).sort();
      const lines = Array.from(lineSet);
      
      const productionSeries = lines.map(lineCode => {
        const data = dates.map(date => {
          const item = this.lineTableData.find(i => i.line_code === lineCode && i.production_date === date);
          return item ? item.total_actual_qty : 0;
        });
        
        return {
          name: lineCode,
          type: 'bar',
          data
        };
      });
      
      this.lineProductionChart.setOption({
        title: { text: '产线产量趋势' },
        xAxis: { type: 'category', data: dates },
        yAxis: { type: 'value' },
        series: productionSeries
      });
      
      // 完成率趋势图
      const rateSeries = lines.map(lineCode => {
        const data = dates.map(date => {
          const item = this.lineTableData.find(i => i.line_code === lineCode && i.production_date === date);
          return item ? item.completion_rate : 0;
        });
        
        return {
          name: lineCode,
          type: 'line',
          data
        };
      });
      
      this.lineRateChart.setOption({
        title: { text: '产线完成率趋势' },
        xAxis: { type: 'category', data: dates },
        yAxis: { type: 'value', max: 100 },
        series: rateSeries
      });
    },
    
    // 产线报表分页
    handleLineSizeChange(val) {
      this.lineQueryForm.pageSize = val;
      this.queryLineDaily();
    },
    handleLineCurrentChange(val) {
      this.lineQueryForm.page = val;
      this.queryLineDaily();
    },
    
    // 查询组轴分栏报表
    async queryAxisReport() {
      try {
        const res = await axios.post('/api/report/axis', this.axisQueryForm);
        if (res.data.code === 200) {
          this.axisTableData = res.data.data.data;
          this.axisTotal = res.data.data.pagination.total;
          
          // 更新图表
          this.updateAxisCharts();
        } else {
          this.$message.error(res.data.message);
        }
      } catch (e) {
        this.$message.error('查询失败');
        console.error(e);
      }
    },
    
    // 更新组轴图表
    updateAxisCharts() {
      // 按分组轴聚合数据
      const groupMap = {};
      this.axisTableData.forEach(item => {
        const key = item[this.axisQueryForm.groupAxis];
        if (!groupMap[key]) {
          groupMap[key] = {
            totalQty: 0,
            avgRate: 0,
            count: 0
          };
        }
        groupMap[key].totalQty += item.actual_qty;
        groupMap[key].avgRate += item.completion_rate;
        groupMap[key].count += 1;
      });
      
      const xData = Object.keys(groupMap);
      const seriesData = xData.map(key => groupMap[key].totalQty);
      const rateData = xData.map(key => (groupMap[key].avgRate / groupMap[key].count).toFixed(2));
      
      this.axisProductionChart.setOption({
        title: { text: `按${this.axisQueryForm.groupAxis === 'line_code' ? '产线' : this.axisQueryForm.groupAxis === 'product_code' ? '产品' : '班次'}分组产量` },
        tooltip: {
          trigger: 'axis',
          formatter: function(params) {
            const key = params[0].name;
            return `${key}<br/>
              总产量:${params[0].value}<br/>
              平均完成率:${rateData[xData.indexOf(key)]}%`;
          }
        },
        xAxis: { type: 'category', data: xData },
        yAxis: [{ type: 'value', name: '产量' }, { type: 'value', name: '完成率(%)', max: 100 }],
        series: [
          {
            name: '总产量',
            type: 'bar',
            data: seriesData
          },
          {
            name: '平均完成率',
            type: 'line',
            yAxisIndex: 1,
            data: rateData
          }
        ]
      });
    },
    
    // 组轴报表分页
    handleAxisSizeChange(val) {
      this.axisQueryForm.pageSize = val;
      this.queryAxisReport();
    },
    handleAxisCurrentChange(val) {
      this.axisQueryForm.page = val;
      this.queryAxisReport();
    }
  }
});

第四步:Electron 主进程(main/index.js)

javascript

运行

复制代码
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const expressApp = require('../server/app');

// 保持窗口引用
let mainWindow;

function createWindow() {
  // 创建浏览器窗口
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  // 加载Express服务的首页
  mainWindow.loadURL('http://localhost:3000');

  // 打开开发者工具(开发环境)
  // mainWindow.webContents.openDevTools();

  // 窗口关闭事件
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}

// Electron就绪后创建窗口
app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

// 所有窗口关闭时退出
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

// 预加载脚本(main/preload.js)
// 用于渲染进程和主进程通信

第五步:运行与打包

1. 运行项目

bash

运行

复制代码
# 安装依赖
npm install

# 启动项目
npm start
2. 打包为桌面应用(需安装 electron-builder)

bash

运行

复制代码
# 安装打包工具
npm install electron-builder --save-dev

# 打包(Windows)
npx electron-builder --win --dir

# 打包(macOS)
npx electron-builder --mac --dir

# 打包(Linux)
npx electron-builder --linux --dir

三、核心功能说明

1. 高索引查询封装

  • 使用better-sqlite3预编译 SQL,提升执行效率;
  • 为核心查询维度(产线 + 日期、产品 + 日期)创建复合索引;
  • 分页查询避免全量加载,降低内存占用。

2. 组轴分栏报表

  • 支持产线 / 产品 / 班次多维度分组;
  • 分栏展示表格 + 双轴图表,支持数据钻取;
  • 自定义分组轴,适配不同报表展示需求。

3. Tab 标签多界面

  • 支持多报表同时打开,标签式切换;
  • 标签可关闭,界面状态记忆;
  • 每个 Tab 独立加载数据,互不干扰。

4. 本地数据库存储

  • 所有数据存储在 SQLite 文件中,无需服务端;
  • 支持离线使用,数据本地加密(可扩展);
  • 自动初始化测试数据,开箱即用。

总结

关键点回顾

  1. 技术栈适配:Node.js + SQLite + Electron 完美适配 MES/ERP 本地化部署需求,无需依赖外部服务;
  2. 查询效率优化:高索引封装 + 预编译 SQL + 分页查询,保证大数据量报表的查询性能;
  3. 可视化展示:基于 ECharts 实现组轴分栏报表,支持多维度数据展示和趋势分析;
  4. 多界面交互:Tab 标签式界面设计,支持多报表同时操作,提升用户体验;
  5. 可扩展性:模块化设计,可快速扩展新报表类型、新查询规则、新可视化图表。

该项目完整实现了 MES/ERP 报表场景的核心需求,既保留了 Node.js 的高效异步特性,又通过 Electron 实现了桌面应用的便捷性,是本地化报表系统的理想解决方案。

相关推荐
九章-1 小时前
医疗系统数据库选型技术指南:从合规性到高性能的全方位考量
数据库·信创·医疗信创
Predestination王瀞潞2 小时前
4.1.1 存储->数据库:MongoDB
数据库·mongodb
JZC_xiaozhong2 小时前
ERP与MES制造数据同步:痛点破解与高效落地实践
大数据·数据库·制造·数据传输·数据孤岛解决方案·数据集成与应用集成·异构数据整合
尽兴-2 小时前
超越缓存:Redis Stack 如何将 Redis 打造成全能实时数据平台
数据库·redis·缓存·redis stack
Doris8932 小时前
【Node.js 】Node.js 与 Webpack 模块化工程化入门指南
前端·webpack·node.js
一个有温度的技术博主2 小时前
Redis系列七:Java客户端Jedis的入门
java·数据库·redis
枕布响丸辣2 小时前
【无标题】
数据库·oracle
Cory.眼2 小时前
MySQL语法错误与修正指南
数据库·sql·oracle
LSL666_2 小时前
Redis值数据类型——sorted set
数据库·redis·缓存·数据类型