HTML 数据日报邮件开发笔记

274 阅读6分钟

前段时间接了个向大领导汇报公司各项目各种数据指标的日报邮件需求。如今搞差不多了,记录下开发过程。

需求

  • 根据数据方提供的多个数据 CSV 文件,获取所需 JS 对象数据。
  • 按需求实现各种不同样式的 Chart 图表,包括折线图、分组柱状图、堆叠柱状图、饼图等等……
  • 按需求实现 Table 表格,除了常规表格外,还需要添加红涨绿跌的背景进度条。
  • 除了 chart 和 table 之外,还有一些如看板、封面、特殊页面的需求。
  • 基于不同项目,需要定制不同的风格样式。
  • 定期每天发送一次日报邮件。

技术方案

方案一 HTML 拼接

一开始接到的需求很简单,说只是将数据以 chart 和 table 的方式展示出来就好。所以我想的办法是传统的 HTML 拼接的方式,大概如下:

function genHtml(values) {
  const base64Url = genChart(values);

  var html = fs.readFileSync("./templete.html", { encoding: "utf-8" });

  var content = "";
  content += "<thead><tr>";
  values.forEach((value) => {
    content += `<td>${value.x}</td>`;
  });
  content += "<td>图表</td>";
  content += "</tr></thead>";

  content += "<tr>";
  values.forEach((value) => {
    content += `<td>${value.y}</td>`;
  });
  content += `<td><img src="${base64Url}" /></td>`;
  content += "</tr>";

  html = html.replace("<!-- NODE_CONTENT_AREA -->", content);
  // console.log("html", html);

  // fs.writeFileSync(`./line-chart.html`, html);
  return html;
}

由于邮件中需要带有 Chart 图片,在前端生成 Chart 需要引入第三方库并且写一堆 JS 代码的,但邮件 HTML 中为了安全是不执行 JS 脚本的。

所以我想着使用 node.js 后端去生成图片:

const fs = require("fs");
const VChart = require("@visactor/vchart");
const Canvas = require("canvas");

// 生成 chart
function genChart(values) {
  // 正常的图表 spec 配置
  const spec = {
    type: "line",
    width: 200,
    height: 100,
    data: [
      {
        id: "Map",
        values,
      },
    ],
    xField: "x",
    yField: "y",
    seriesField: "legend",
    point: {
      visible: false,
    },
    axes: [
      {
        orient: "left",
        visible: false,
      },
      {
        orient: "bottom",
        visible: false,
      },
    ],
  };
  const cs = new VChart.default(spec, {
    // 声明使用的渲染环境以及传染对应的渲染环境参数
    mode: "node",
    modeParams: Canvas,
    animation: false, // 关闭动画
  });

  cs.renderSync();

  // 导出图片
  const buffer = cs.getImageBuffer();

  // buffer 转 base64
  function bufferToBase64(imageBuffer) {
    return (
      "data:image/png;base64," +
      Buffer.from(imageBuffer, "binary").toString("base64")
    );
  }

  fs.writeFileSync(`./line-chart.png`, buffer);

  return bufferToBase64(buffer);
}

以上代码用 VChart 库生成了 Chart 的 base64 图片链接,插入到了 <img src="{url}" > 中。但是……发现某些邮箱对 base64 并不支持,会变成空白的情况。

要解决以上问题,只有把图片放到静态服务器,通过真正的 URL 地址来解决。但这种方式也会比较麻烦,需要一个地方存储图片资源。

不过,正当我卡在这一步的时候,又来了一大大大波需求。具体需求就是上文那些。我发现当前方案完全无法执行下去了。

和需求方沟通后,对方同意使用长图的方式来展示日报内容。

方案二 网页内容截长图

有了长图方案就感觉舒服了很多,它的好处就在于可以自由在网页中去处理各种逻辑、各种样式,使用各种第三方库。

下面简单说下方案,这里的后端是我自己写的 node 后端。其实我项目中用的是后端同学写的脚本,不过大致意思是一样的。

前端侧

  • 前端创建 vue 项目
  • 数据方提供 CSV 文件,并将 CSV 放到 public/csv_folder/ 目录下。
  • 通过浏览器 fetch 方法逐个获取 csv 文件,并通过 papaparse 解析 csv 文件内容
const status = ref(0); // 0 加载中 1 成功 2 失败
const PageData = ref([]);

const GAME_NAME = "炉石传说";
const config = [
  {
    no: "dashboard",
    name: "看板",
    outside: true,
  },
  {
    no: "1-2",
    level: 0,
    name: GAME_NAME + "-近7日各用户来源新增数-同环比",
    alias: "新增量-分用户来源-同环比",
    chart: false,
    table: true,
  },
  {
    no: "2-4",
    level: 0,
    name: GAME_NAME + "-各广告媒体-关键指标",
    alias: "各广告媒体-关键指标",
    chart: false,
    table: true,
  },
];

onMounted(() => {
  getData();
});

async function getData() {
  status.value = 0;
  const result = [];

  try {
    for (let i = 0; i < config.length; i++) {
      const cf = config[i];

      // 处理一些特殊需求的报表,需要写单独的 Vue 页面处理
      if (cf.outside) {
        result.push(cf);
        continue;
      }

      const url = `/csv_folder/${gameId}/${cf.name}.csv`;
      console.log("fetch csv", url);

      await fetch(url)
        .then((response) => response.text())
        .then(async (content) => {
          if (content.includes("<!doctype html>") || content === "") return;
          const { data, errors, meta } = await papaparse.parse(content, {
            header: true, // 如果第一行是标题
            dynamicTyping: true,
            skipEmptyLines: true,
          });

          result.push({
            name: cf.name,
            headers: meta.fields,
            data: data,
          });
        })
        .catch((err) => console.log(err));
    }

    status.value = 2;
  } catch (error) {
    status.value = 1;
  }

  // 已经拿到了图和表的数据,对数据进行处理
  PageData.value = genChartAndTableData(result);
}
  • 数据拿到后就是正常的前端开发工作了,我是基于 G2Plot 和 element 的表格组件来开发图和表的。
  • 为了满足多个游戏不同样式风格的需求,我在 Vite 配置文件中搞成了多入口多输出的模式。每个游戏项目创建一个 .html 文件,以此为入口实现不同的样式、界面、逻辑的开发。
// vite.config.js
import { fileURLToPath, URL } from "node:url";
import { resolve } from "node:path";

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
  build: {
    rollupOptions: {
      input: {
        index: resolve(__dirname, "./index.html"),
        5078: resolve(__dirname, "./5078.html"),
        5199: resolve(__dirname, "./5199.html"),
        5256: resolve(__dirname, "./5256.html"),
      },
    },
  },
  server: {
    host: "0.0.0.0",
  },
});
  • 最后开发完成后将打包的 dist 目录部署到静态 WEB 服务器

至此,前端工作完成。

数据后端侧

  • 将数据 CSV 文件全局覆盖更新到存放 CSV 的制定目录 /csv_folder
  • 访问前端页面,页面会根据提供的 CSV 渲染所需网页。
  • 网页渲染成功后(我会在网页渲染成功后创建一个 #success 的 div 元素),使用工具将指定 DOM 进行长图截取。下面是我用 node 写的示例:
import puppeteer from "puppeteer";

(async () => {
  const browser = await puppeteer.launch({
    args: ["--no-sandbox"],
  });

  const page = await browser.newPage();
  await page.goto("http://192.111.111.111:5173/");
  await page.setViewport({ width: 1452, height: 1024 });

  // 截图,在日报页面渲染出来后 #success 的 div 才会出现用来提示抓包截图
  const fileElement = await page.waitForSelector("#success");
  await fileElement.screenshot({
    path: "./output/daily.png",
  });
  console.log("生成 PNG 文件成功!");

  // PDF 生成,本来也要当附件上传的,后面没用上
  await page.pdf({
    path: "./output/daily.pdf",
  });
  console.log("生成 PDF 文件成功!");

  await browser.close();
})();
  • 将长图作为邮件附件。通过 HTML 的 <img> 标签来展示长图。
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  </head>

  <body style="margin: 0; padding: 0">
    <img
      width="1200"
      height="15201"
      style="width:12.5in;height:158.34375in"
      src="cid:result.png"
      align="left"
      hspace="12"
      v:shapes="图片_x0020_1" />
  </body>
</html>

tips 1: 这里的 cid: 就是获取邮件附件的一种方式,在 nodemailer 中也有类似功能。

let message = {
    ...
    html: 'Embedded image: <img src="cid:unique@nodemailer.com"/>',
    attachments: [{
        filename: 'image.png',
        path: '/path/to/file',
        cid: 'unique@nodemailer.com' //same cid value as in the html img src
    }]
}

tips 2: <img> 的宽高并不能只写 style 就行的,最好把 width、 height 属性都加上更加稳妥。

邮件日报的其他问题

邮件 HTML 的编写受限

由于邮件中 HTML 编写是很受限制的,大量的 HTML 标签无法使用。且为了邮件安全邮件中是无法引入和编写 JavaScript 脚本的。所以是无法实现复杂一些的功能的。

邮件 HTML 不支持 Base64 图片

通过 VChart 生成的 Base64 图片,在插入邮件 HTML 中后也有部分邮箱不支持显示。

邮件 HTML 的编写方式原始

从网上搜到的资料来看,对邮件的 HTML 编写只能用最基础的 TABLE 嵌套的方式来进行布局,且 CSS 样式的支持程度也不完全,只能用最基础和原始的那些属性。

我也去看了不少主流网站发的邮件,基本也都是 table 嵌套的方式去实现的。可见只有这种方式是比较稳妥的。不过这也一来也就限制了复杂 HTML 页面实现的可能性。

邮件 HTML 兼容性差

实际用下来发现,其实想 Foxmail、QQ 邮箱等新版本的邮箱应用和网页支持度蛮高的,有的甚至可以执行 JS 脚本。

但是……对于老版本的邮箱,如 outlook 之类的支持程度就很一般了。另外现在还需要去兼容移动端的邮件 APP。兼容的难度变得很大。

HTML 邮件容器模板

一开始网上找了几个邮件 HTML 模板,但都不太好用。偶尔间发现邮件客户端也是可以查看 HTML 源代码的。直接从邮箱客户端源代码区域复制粘贴一份,瞬间好用了~

如何做到多个游戏对日报页面的复用

由于多个游戏项目要显示的内容大同小异,所以我就直接通过多入口多输出的方式,控制入口 HTML 文件。

如此就可以做到几个页面完全独立了,但在开发中却可以对各个组件模块进行抽离和复用。逐渐提升开发效率。

对于表格过宽或者过长的解决方案

对于这种表格,解决方式就是调整表格列宽,并缩放整体大小,尽量保证表格宽度刚好撑满页面,而表格的高度也不超过底部。

.table-scale {
  transform: scale(0.8);
  transform-origin: left top;
}

图片生成和发送邮件

一开始接到需求的时候说是都由我这边处理,所以我用 node.js 来写了定时任务 nodemailer 邮件发送、抓包工具 puppeteer 长图截取这些功能。

不过后面发现后端同学也写了个类似的,就用了后端方案,并且他在此方案上给数据同学提供了一个脚本。让存放 CSV 文件、访问网页、截图、发送邮件这一系列事情自动化定时完成了。

总结

由于涉及到数据信息,所以演示图片一个木有~只能代码加文字大概记录下。

其实技术上并不难,主要以此文记录下本次开发的前期开发方式的探索、中期遇到的问题、后期前段、后端、数据端的三分沟通联调。