需求
根据给到的资料,设计邮件模板,并且将生成的页面发送到用户的邮箱中。
思路
对需求进行分析,可以拆分成多个小目标。
- 根据需求设计并实现静态邮件模板页面
- 实现邮件页面的动态生成
- 发送邮件
实践
step1 邮件模板静态页面
根据给到的参考资料,要实现的是数据报表,使用table实现。 实现的页面如下:
step2 实现邮件模板的动态生成
动态生成前端页面有多种方法,根据复杂度、灵活性和项目需求可以选择不同的方案。常见的方式有:
- 字符串拼接/模板字面量。最简单直接的方式,适合简单的HTML结构。
- DOM API 动态创建元素,适合需要在生成前后进行DOM操作的场景:
- 使用模板引擎(例如:EJS)
- 使用现代前端框架 (React, Vue等)
- Web Components
- 使用专用HTML生成库
我要实现的是一个简单的邮件模板页面,所以字符串模板或模板字面量是最优选。具体实现如下:
// report.js
const reportData = {
baseData: {
day: {
deplete: 12500,
new: 1200,
dau: 3475,
ad_revenue: 10000,
ad_money: 10000,
gross_income: 20000,
profit: "",
},
month: {
deplete: 12500,
new: 1200,
dau: "",
ad_revenue: 10000,
ad_money: 10000,
gross_income: 20000,
profit: 30032,
},
total: {
deplete: 12500,
new: 1200,
dau: 3475,
ad_revenue: 10000,
ad_money: 10000,
gross_income: 20000,
profit: 234533,
},
},
deliveryData: {
seven_days: {
deplete: 12500,
new: 1200,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
day_1_ROI: 0.78,
},
chain: {
deplete: 0.15,
new: 0.22,
enrollment_costs: -0.05,
new_payment_rate: 0.007,
paid_costs: 0.033,
day_1_ROI: -0.78,
},
last_week: {
deplete: 12500,
new: 1200,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
day_1_ROI: 0.78,
},
year_on_year: {
deplete: 0.15,
new: 0.22,
enrollment_costs: -0.05,
new_payment_rate: 0.007,
paid_costs: 0.033,
day_1_ROI: -0.78,
},
month: {
deplete: 12500,
new: 1200,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
day_1_ROI: 0.78,
},
total: {
deplete: 12500,
new: 1200,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
day_1_ROI: 0.78,
},
},
performanceData: {
seven_days: {
load_time: 1872,
conversion_rate: 0.221,
},
chain: { load_time: 0.01872, conversion_rate: 0.0221 },
},
retainData: {
register: {
new: 0.452,
seven_days: 0.45,
chain: 0.58,
last_week: 0.54,
year_on_year: 0.52,
},
pay: {
new: 0.452,
seven_days: 0.45,
chain: 0.58,
last_week: 0.54,
year_on_year: 0.52,
},
},
revenueDataDetail: [
{
date: "2025-05-06",
total_revenue: 10000,
ad_revenue: 5000,
ad_money: 5000,
ad_money1: 5000,
deplete: 12500,
new: 1200,
dau: 3475,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
pay_arpu: 0.001,
active_arpu: 0.001,
day_1_ROI: 0.78,
day_3_ROI: 0.78,
day_7_ROI: 0.78,
day_14_ROI: 0.78,
day_30_ROI: 0.78,
day_60_ROI: 0.78,
day_90_ROI: 0.78,
},
{
date: "2025-05-07",
total_revenue: 10000,
ad_revenue: 5000,
ad_money: 5000,
ad_money1: 5000,
deplete: 12500,
new: 1200,
dau: 3475,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
pay_arpu: 0.001,
active_arpu: 0.001,
day_1_ROI: 0.78,
day_3_ROI: 0.78,
day_7_ROI: 0.78,
day_14_ROI: 0.78,
day_30_ROI: 0.78,
day_60_ROI: 0.78,
day_90_ROI: 0.78,
},
{
date: "2025-05-08",
total_revenue: 10000,
ad_revenue: 5000,
ad_money: 5000,
ad_money1: 5000,
deplete: 12500,
new: 1200,
dau: 3475,
enrollment_costs: 10.42,
new_payment_rate: 0.052,
paid_costs: 4.5,
pay_arpu: 0.001,
active_arpu: 0.001,
day_1_ROI: 0.78,
day_3_ROI: 0.78,
day_7_ROI: 0.78,
day_14_ROI: 0.78,
day_30_ROI: 0.78,
day_60_ROI: 0.78,
day_90_ROI: 0.78,
},
],
retainDataDetail: [
{
date: "2025-05-06",
new: 12344,
ret_1: 0.45,
ret_3: 0.45,
ret_7: 0.45,
ret_14: 0.45,
ret_30: 0.45,
ret_60: 0.45,
ret_90: 0.45,
first_pay_rate: 0.45,
first_pay_num: 1000,
pay_1: 0.45,
pay_3: 0.45,
pay_7: 0.45,
pay_14: 0.45,
pay_30: 0.45,
pay_60: 0.45,
pay_90: 0.45,
},
{
date: "2025-05-07",
new: 12344,
ret_1: 0.45,
ret_3: 0.45,
ret_7: 0.45,
ret_14: 0.45,
ret_30: 0.45,
ret_60: 0.45,
ret_90: 0.45,
first_pay_rate: 0.45,
first_pay_num: 1000,
pay_1: 0.45,
pay_3: 0.45,
pay_7: 0.45,
pay_14: 0.45,
pay_30: 0.45,
pay_60: 0.45,
pay_90: 0.45,
},
{
date: "2025-05-08",
new: 12344,
ret_1: 0.45,
ret_3: 0.45,
ret_7: 0.45,
ret_14: 0.45,
ret_30: 0.45,
ret_60: 0.45,
ret_90: 0.45,
first_pay_rate: 0.45,
first_pay_num: 1000,
pay_1: 0.45,
pay_3: 0.45,
pay_7: 0.45,
pay_14: 0.45,
pay_30: 0.45,
pay_60: 0.45,
pay_90: 0.45,
},
],
};
//获取日期
const getDate = (type) => {
const weekday = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
];
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const week = weekday[date.getDay()];
return type === 1
? `${year}年${month}月${day}日 ${week}`
: `${month}月${day}日 ${week}`;
};
// 辅助函数
const formatNumber = (num) => {
if (num === "" || num === null || num === undefined) return "-";
if (typeof num === "number") {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
return num;
};
const formatPercent = (num) => {
if (num === "" || num === null || num === undefined) return "-";
if (typeof num === "number") {
return (num * 100).toFixed(2) + "%";
}
return num;
};
const formatChange = (change) => {
if (change === "" || change === null || change === undefined) return "-";
if (change > 0)
return `<span class="positive">↑${(change * 100).toFixed(2)}%</span>`;
if (change < 0)
return `<span class="negative">↓${Math.abs(change * 100).toFixed(
2
)}%</span>`;
return `→0%`;
};
const formatROI = (roi) => {
if (roi === "" || roi === null || roi === undefined) return "-";
return roi.toFixed(2);
};
generatoReport = function () {
const data = reportData;
return `
<!DOCTYPE html>
<html>
<head>
<style>
body,table,td { margin: 0;padding: 0;border: 0;line-height: 1.5;}
html {background-color: #f0f2f5;}
.container {max-width: 1000px;margin: 40px auto;background-color: #ffffff;border-radius: 12px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);}
.header {padding: 32px;background: linear-gradient(135deg, #2c3e50, #3498db);color: white;border-radius: 12px 12px 0 0;}
.title { font-size: 24px; font-weight: 700; margin: 0;}
.sub-title { font-size: 14px;color: #e0f3ff; margin-top: 8px;}
.content {padding: 32px;}
.section { margin-bottom: 32px; }
.section-title {font-size: 18px;font-weight: 600;color: #2c3e50;border-left: 4px solid #3498db;padding-left: 12px;margin-bottom: 20px;}
.data-table {width: 100%;border-collapse: collapse;margin-top: 16px;font-size: 14px;}
.data-table th,.data-table td {padding: 12px;text-align: center;border: 1px solid #e0e0e0;}
.data-table th {background-color: #f0f2f5;color: #333;}
.data-table tr:hover {background-color: #f8f9fa;}
.positive { color: #f5222d; } /* 红色表示正向变化 */
.negative { color: #52c41a; } /* 绿色表示负向变化 */
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">产品运营数据分析报表</h1>
<p class="sub-title">${getDate(1)}</p>
</div>
<div class="content">
<div class="section">
<h2 class="section-title">1. 基本数据</h2>
<table class="data-table">
<colgroup>
<col style="width:120px"></col>
</colgroup>
<tbody>
<tr>
<th rowspan="2">日期</th>
<th rowspan="2">消耗</th>
<th rowspan="2">新增</th>
<th rowspan="2">DAU</th>
<th colspan="3">收入</th>
<th rowspan="2">毛利</th>
</tr>
<tr>
<td>广告收入</td>
<td>广告金</td>
<td>总收入</td>
</tr>
<tr>
<td>${getDate(2)}</td>
<td>${formatNumber(data.baseData.day.deplete)}</td>
<td>${formatNumber(data.baseData.day.new)}</td>
<td>${formatNumber(data.baseData.day.dau)}</td>
<td>${formatNumber(data.baseData.day.ad_revenue)}</td>
<td>${formatNumber(data.baseData.day.ad_money)}</td>
<td>${formatNumber(data.baseData.day.gross_income)}</td>
<td>${formatNumber(data.baseData.day.profit)}</td>
</tr>
<tr>
<td>本月累计</td>
<td>${formatNumber(data.baseData.month.deplete)}</td>
<td>${formatNumber(data.baseData.month.new)}</td>
<td>${formatNumber(data.baseData.month.dau)}</td>
<td>${formatNumber(data.baseData.month.ad_revenue)}</td>
<td>${formatNumber(data.baseData.month.ad_money)}</td>
<td>${formatNumber(data.baseData.month.gross_income)}</td>
<td>${formatNumber(data.baseData.month.profit)}</td>
</tr>
<tr>
<td>历史累计</td>
<td>${formatNumber(data.baseData.total.deplete)}</td>
<td>${formatNumber(data.baseData.total.new)}</td>
<td>${formatNumber(data.baseData.total.dau)}</td>
<td>${formatNumber(data.baseData.total.ad_revenue)}</td>
<td>${formatNumber(data.baseData.total.ad_money)}</td>
<td>${formatNumber(data.baseData.total.gross_income)}</td>
<td>${formatNumber(data.baseData.total.profit)}</td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2 class="section-title">2. 投放数据</h2>
<table class="data-table">
<colgroup>
<col style="width:120px"></col>
</colgroup>
<tbody>
<tr>
<th>日期</th>
<th>指标</th>
<th>消耗</th>
<th>新增</th>
<th>注册成本</th>
<th>新增付费率</th>
<th>付费成本</th>
<th>首日ROI(内购)</th>
</tr>
<tr>
<td rowspan="4">${getDate(2)}</td>
<td>近7日日均</td>
<td>${formatNumber(data.deliveryData.seven_days.deplete)}</td>
<td>${formatNumber(data.deliveryData.seven_days.new)}</td>
<td>${formatNumber(
data.deliveryData.seven_days.enrollment_costs
)}</td>
<td>${formatPercent(
data.deliveryData.seven_days.new_payment_rate
)}</td>
<td>${formatNumber(
data.deliveryData.seven_days.paid_costs
)}</td>
<td>${formatROI(data.deliveryData.seven_days.day_1_ROI)}</td>
</tr>
<tr>
<td>环比</td>
<td>${formatChange(data.deliveryData.chain.deplete)}</td>
<td>${formatChange(data.deliveryData.chain.new)}</td>
<td>${formatChange(
data.deliveryData.chain.enrollment_costs
)}</td>
<td>${formatChange(
data.deliveryData.chain.new_payment_rate
)}</td>
<td>${formatChange(data.deliveryData.chain.paid_costs)}</td>
<td>${formatChange(data.deliveryData.chain.day_1_ROI)}</td>
</tr>
<tr>
<td>上周同期</td>
<td>${formatNumber(data.deliveryData.last_week.deplete)}</td>
<td>${formatNumber(data.deliveryData.last_week.new)}</td>
<td>${formatNumber(
data.deliveryData.last_week.enrollment_costs
)}</td>
<td>${formatPercent(
data.deliveryData.last_week.new_payment_rate
)}</td>
<td>${formatNumber(data.deliveryData.last_week.paid_costs)}</td>
<td>${formatROI(data.deliveryData.last_week.day_1_ROI)}</td>
</tr>
<tr>
<td>同比</td>
<td>${formatChange(data.deliveryData.year_on_year.deplete)}</td>
<td>${formatChange(data.deliveryData.year_on_year.new)}</td>
<td>${formatChange(
data.deliveryData.year_on_year.enrollment_costs
)}</td>
<td>${formatChange(
data.deliveryData.year_on_year.new_payment_rate
)}</td>
<td>${formatChange(
data.deliveryData.year_on_year.paid_costs
)}</td>
<td>${formatChange(
data.deliveryData.year_on_year.day_1_ROI
)}</td>
</tr>
<tr>
<td>本月累计</td>
<td></td>
<td>${formatNumber(data.deliveryData.month.deplete)}</td>
<td>${formatNumber(data.deliveryData.month.new)}</td>
<td>${formatNumber(
data.deliveryData.month.enrollment_costs
)}</td>
<td>${formatPercent(
data.deliveryData.month.new_payment_rate
)}</td>
<td>${formatNumber(data.deliveryData.month.paid_costs)}</td>
<td>${formatROI(data.deliveryData.month.day_1_ROI)}</td>
</tr>
<tr>
<td>历史累计</td>
<td></td>
<td>${formatNumber(data.deliveryData.total.deplete)}</td>
<td>${formatNumber(data.deliveryData.total.new)}</td>
<td>${formatNumber(
data.deliveryData.total.enrollment_costs
)}</td>
<td>${formatPercent(
data.deliveryData.total.new_payment_rate
)}</td>
<td>${formatNumber(data.deliveryData.total.paid_costs)}</td>
<td>${formatROI(data.deliveryData.total.day_1_ROI)}</td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2 class="section-title">3. 性能数据</h2>
<table class="data-table">
<colgroup>
<col style="width:120px"></col>
</colgroup>
<tr>
<th>日期</th>
<th>指标</th>
<th>加载时长ms</th>
<th>转化率</th>
</tr>
<tr>
<td rowspan="2">${getDate(2)}</td>
<td>近7日日均</td>
<td>${formatNumber(
data.performanceData.seven_days.load_time
)}</td>
<td>${formatPercent(
data.performanceData.seven_days.conversion_rate
)}</td>
</tr>
<tr>
<td>环比</td>
<td>${formatChange(data.performanceData.chain.load_time)}</td>
<td>${formatChange(
data.performanceData.chain.conversion_rate
)}</td>
</tr>
</table>
</div>
<div class="section">
<h2 class="section-title">4. 留存数据</h2>
<table class="data-table">
<colgroup>
<col style="width:120px"></col>
</colgroup>
<tr>
<th>指标</th>
<th>最新数据</th>
<th>近7日日均</th>
<th>环比</th>
<th>上周同期</th>
<th>同比</th>
</tr>
<tr>
<td>注册次留</td>
<td>${formatPercent(data.retainData.register.new)}</td>
<td>${formatPercent(data.retainData.register.seven_days)}</td>
<td>${formatChange(data.retainData.register.chain)}</td>
<td>${formatPercent(data.retainData.register.last_week)}</td>
<td>${formatChange(data.retainData.register.year_on_year)}</td>
</tr>
<tr>
<td>付费次留</td>
<td>${formatPercent(data.retainData.pay.new)}</td>
<td>${formatPercent(data.retainData.pay.seven_days)}</td>
<td>${formatChange(data.retainData.pay.chain)}</td>
<td>${formatPercent(data.retainData.pay.last_week)}</td>
<td>${formatChange(data.retainData.pay.year_on_year)}</td>
</tr>
</table>
</div>
<div class="section">
<h2 class="section-title">5. 详细数据-收入</h2>
<div style="overflow-x: auto">
<table class="data-table" style="min-width:1800px">
<colgroup>
<col style="width:100px"></col>
<col span=9 style="width:90px"></col>
<col style="width:100px"></col>
<col style="width:120px"></col>
<col style="width:90px"></col>
</colgroup>
<tr>
<th rowspan="2">日期</th>
<th colspan="4">收入</th>
<th rowspan="2">消耗</th>
<th rowspan="2">新增</th>
<th rowspan="2">DAU</th>
<th rowspan="2">注册成本</th>
<th rowspan="2">付费成本</th>
<th rowspan="2">首日付费率</th>
<th rowspan="2">新增付费Arpu</th>
<th rowspan="2">活跃Arpu</th>
<th colspan="7">内购ROI</th>
</tr>
<tr>
<th>总收入</th>
<th>内购收入</th>
<th>广告收入</th>
<th>广告金</th>
<th>首日</th>
<th>3日</th>
<th>7日</th>
<th>14日</th>
<th>30日</th>
<th>60日</th>
<th>90日</th>
</tr>
${data.revenueDataDetail
.map(
(item) => `
<tr>
<td>${item.date}</td>
<td>${formatNumber(item.total_revenue)}</td>
<td>-</td>
<td>${formatNumber(item.ad_revenue)}</td>
<td>${formatNumber(item.ad_money)}</td>
<td>${formatNumber(item.deplete)}</td>
<td>${formatNumber(item.new)}</td>
<td>${formatNumber(item.dau)}</td>
<td>${formatNumber(item.enrollment_costs)}</td>
<td>${formatNumber(item.paid_costs)}</td>
<td>${formatPercent(item.new_payment_rate)}</td>
<td>${formatNumber(item.pay_arpu)}</td>
<td>${formatNumber(item.active_arpu)}</td>
<td>${formatROI(item.day_1_ROI)}</td>
<td>${formatROI(item.day_3_ROI)}</td>
<td>${formatROI(item.day_7_ROI)}</td>
<td>${formatROI(item.day_14_ROI)}</td>
<td>${formatROI(item.day_30_ROI)}</td>
<td>${formatROI(item.day_60_ROI)}</td>
<td>${formatROI(item.day_90_ROI)}</td>
</tr>
`
)
.join("")}
</table>
</div>
</div>
<div class="section">
<h2 class="section-title">6. 详细数据-留存</h2>
<div style="overflow-x: auto">
<table class="data-table" style="min-width: 1600px">
<colgroup>
<col style="width:100px"></col>
<col style="width:80px"></col>
<col span=7 style="width:80px"></col>
<col span=2 style="width:110px"></col>
</colgroup>
<tr>
<th rowspan="2">日期</th>
<th rowspan="2">新增</th>
<th colspan="7">留存</th>
<th rowspan="2">首日付费率</th>
<th rowspan="2">首日付费人数</th>
<th colspan="7">付费留存</th>
</tr>
<tr>
<th>次留</th>
<th>3留</th>
<th>7留</th>
<th>14留</th>
<th>30留</th>
<th>60留</th>
<th>90留</th>
<th>次留</th>
<th>3留</th>
<th>7留</th>
<th>14留</th>
<th>30留</th>
<th>60留</th>
<th>90留</th>
</tr>
${data.retainDataDetail
.map(
(item) => `
<tr>
<td>${item.date}</td>
<td>${formatNumber(item.new)}</td>
<td>${formatPercent(item.ret_1)}</td>
<td>${formatPercent(item.ret_3)}</td>
<td>${formatPercent(item.ret_7)}</td>
<td>${formatPercent(item.ret_14)}</td>
<td>${formatPercent(item.ret_30)}</td>
<td>${formatPercent(item.ret_60)}</td>
<td>${formatPercent(item.ret_90)}</td>
<td>${formatPercent(item.first_pay_rate)}</td>
<td>${formatNumber(item.first_pay_num)}</td>
<td>${formatPercent(item.pay_1)}</td>
<td>${formatPercent(item.pay_3)}</td>
<td>${formatPercent(item.pay_7)}</td>
<td>${formatPercent(item.pay_14)}</td>
<td>${formatPercent(item.pay_30)}</td>
<td>${formatPercent(item.pay_60)}</td>
<td>${formatPercent(item.pay_90)}</td>
</tr>
`
)
.join("")}
</table>
</div>
</div>
</div>
</div>
</body>
</html>
`;
};
exports.getDate = getDate;
exports.generatoReport = generatoReport;
step3 发送邮件
(正式应该是由后端发送,现在主要是前端在测试)
我这里选择借助Node.js来实现将页面内容发送到用户邮箱的功能。实现步骤
-
前端部分。
report.js -
Node.js后端部分
首先安装所需依赖: npm install express body-parser nodemailer cors 。运行过程中如果出现报错,要注意node版本
然后创建服务器代码:
const express = require("express");
const bodyParser = require("body-parser");
const nodemailer = require("nodemailer");
const cors = require("cors"); // 引入cors模块,解决跨域
const app = express();
// 引入前端模板页面
const { generatoReport, getDate } = require("./generate-report");
// 使用cors中间件
app.use(cors());
app.use(bodyParser.json());
// 配置邮件服务 (这里以qq为例)
const transporter = nodemailer.createTransport({
host: "smtp.qq.com", // QQ邮箱的SMTP服务器地址
port: 465, // QQ邮箱的SSL端口(加密方式)
secure: true, // 使用SSL加密
auth: {
user: "xxxxxxx@qq.com", // 你的QQ邮箱
pass: "xxxxxxx", // 不是QQ密码,是SMTP授权码
},
});
app.post("/api/send-email", async (req, res) => {
const html = generatoReport();
try {
// 发送邮件
await transporter.sendMail({
from: "xxxxxxx@qq.com",
to: "xxxxxx",
subject: `【${getDate(1)}】产品运营数据分析报表`,
html: html,
text: "您的邮箱客户端不支持HTML邮件,请使用支持HTML的邮件客户端查看。",
});
res.json({ success: true });
return;
} catch (error) {
console.error("邮件发送错误:", error);
res.status(500).json({
success: false,
message: "邮件发送失败: " + error.message,
});
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
最终实现的效果如下: