emailTemplate.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. const sendEmail = require('./Email')
  2. const {
  3. renderEmail,
  4. escapeNl2br,
  5. pEsc,
  6. kv,
  7. panel,
  8. notice,
  9. codeBox
  10. } = require('./emailLayout')
  11. class emailTemplate {
  12. stramptoTime(time) {
  13. if (time < 10)
  14. return ''
  15. return new Date(+time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
  16. }
  17. async checkEmail(email, code) {
  18. const bodyHtml = [
  19. pEsc('尊敬的用户:'),
  20. pEsc('您正在进行邮箱验证。如非本人操作,请忽略本邮件。'),
  21. pEsc('您的验证码为:'),
  22. codeBox(code),
  23. notice('验证码 5 分钟内有效,超时请重新获取。', 'danger')
  24. ].join('')
  25. await sendEmail(
  26. email,
  27. '🔑 邮箱验证码',
  28. renderEmail({
  29. pageTitle: '邮箱验证码',
  30. headline: '🔑 邮箱验证码',
  31. variant: 'default',
  32. bodyHtml,
  33. footerExtra: '请勿向他人泄露验证码。'
  34. })
  35. )
  36. }
  37. async bindEmailSuccess(email) {
  38. const time = this.stramptoTime(new Date().getTime())
  39. const bodyHtml = [
  40. pEsc('尊敬的用户:'),
  41. pEsc('您的沐晨乐跑账号绑定邮箱已成功更新。'),
  42. panel(kv('操作时间', time)),
  43. pEsc('如非本人操作,请尽快联系客服处理。', { last: true })
  44. ].join('')
  45. await sendEmail(
  46. email,
  47. '✅ 邮箱换绑成功',
  48. renderEmail({
  49. pageTitle: '邮箱换绑成功',
  50. headline: '✅ 邮箱换绑成功',
  51. variant: 'success',
  52. bodyHtml
  53. })
  54. )
  55. }
  56. async registerSuccess(email, username) {
  57. const time = new Date().getTime()
  58. const bodyHtml = [
  59. pEsc(`尊敬的 ${username}:`),
  60. pEsc('您已成功注册沐晨乐跑账号,账号信息如下:'),
  61. panel([
  62. kv('用户名', username),
  63. kv('注册时间', this.stramptoTime(time))
  64. ].join('')),
  65. pEsc('感谢您的使用。', { last: true })
  66. ].join('')
  67. await sendEmail(
  68. email,
  69. '✅ 注册成功',
  70. renderEmail({
  71. pageTitle: '注册成功',
  72. headline: '✅ 注册成功',
  73. variant: 'success',
  74. bodyHtml,
  75. footerExtra: '如非本人注册,请尽快联系客服处理。'
  76. })
  77. )
  78. }
  79. async updateSuccess(email, data) {
  80. const autoNote = data.auto_run === 0
  81. ? '当前未开启自动乐跑。如需跑步,请登录沐晨乐跑后手动发起。'
  82. : '已为您开启自动乐跑,系统将按计划代为完成跑步任务,请留意后续邮件提醒。'
  83. const bodyHtml = [
  84. pEsc(`尊敬的 ${data.name}:`),
  85. pEsc('您的乐跑账号登录信息已更新成功,详情如下:'),
  86. panel([
  87. kv('学号', data.account),
  88. kv('年级', data.grade_id),
  89. kv('学院', data.academy_name),
  90. kv('更新时间', this.stramptoTime(new Date().getTime()))
  91. ].join('')),
  92. notice(autoNote, 'neutral'),
  93. notice('请勿在其他设备登录该乐跑账号,以免登录状态失效并需重新绑定。', 'neutral'),
  94. pEsc('如需帮助,请联系沐晨乐跑客服。', { last: true })
  95. ].join('')
  96. await sendEmail(
  97. email,
  98. '✅ 乐跑账号更新成功',
  99. renderEmail({
  100. pageTitle: '乐跑账号更新成功',
  101. headline: '✅ 乐跑账号信息已更新',
  102. variant: 'success',
  103. bodyHtml
  104. })
  105. )
  106. }
  107. async lepaoOver(email, data) {
  108. const targetKm = Number(data.target_km ?? data.target_count) || 0
  109. const monthKm = Number(data.month_km ?? data.term_num) || 0
  110. const progressPanel = targetKm > 0
  111. ? panel([
  112. kv('本月目标', `${targetKm} km`),
  113. kv('本月已完成', `${monthKm} km`)
  114. ].join(''))
  115. : ''
  116. const bodyHtml = [
  117. pEsc(`尊敬的 ${data.name}:`),
  118. pEsc('您设定的乐跑目标已全部完成,系统已为您关闭自动乐跑功能。'),
  119. progressPanel,
  120. pEsc('感谢您的配合。若仍需跑步,可在沐晨乐跑中按需重新开启相关功能。'),
  121. notice('如有疑问,请通过沐晨乐跑工单反馈。', 'neutral'),
  122. pEsc('祝您学习生活愉快。', { last: true })
  123. ].join('')
  124. await sendEmail(
  125. email,
  126. '🏁 乐跑目标已完成',
  127. renderEmail({
  128. pageTitle: '乐跑目标已完成',
  129. headline: '🏁 乐跑目标已全部完成',
  130. variant: 'success',
  131. bodyHtml
  132. })
  133. )
  134. }
  135. async lepaoSuccess(email, data) {
  136. const targetKm = Number(data.target_km ?? data.target_count) || 0
  137. const monthKm = Number(data.month_km ?? data.term_num) || 0
  138. const totalKm = Number(data.total_km ?? data.total_num) || 0
  139. const timeSec = Number(data.time) || 0
  140. const distanceKm = Number(data.distance) || 0
  141. const runZone = data.run_zone_name || data.pass_tit || '—'
  142. const paceStr =
  143. distanceKm > 0 && timeSec > 0
  144. ? this.calculatePace(timeSec, distanceKm)
  145. : '—'
  146. const timeStr = timeSec > 0 ? this.formatSecondsToMinSec(timeSec) : '—'
  147. let goalLines = ''
  148. if (targetKm === 0) {
  149. const cumulative = totalKm || monthKm
  150. goalLines = kv('累计里程', cumulative ? `${cumulative} km` : '—')
  151. } else {
  152. goalLines = [
  153. kv('本月目标', `${targetKm} km`),
  154. kv('本月已完成', `${monthKm} km`),
  155. kv('累计里程', totalKm ? `${totalKm} km` : '—')
  156. ].join('')
  157. }
  158. const bodyHtml = [
  159. pEsc(`尊敬的 ${data.name}:`),
  160. pEsc('系统已成功代您完成一次乐跑,摘要如下:'),
  161. panel([
  162. kv('学号', data.account),
  163. kv('跑区', runZone),
  164. kv('用时', timeStr),
  165. kv('平均配速', paceStr),
  166. kv('距离', distanceKm ? `${distanceKm} km` : '—'),
  167. goalLines
  168. ].join('')),
  169. notice('若已开启自动乐跑,请勿在其他设备登录该乐跑账号,以免登录状态失效。', 'neutral')
  170. ].join('')
  171. await sendEmail(
  172. email,
  173. '✅ 乐跑成功通知',
  174. renderEmail({
  175. pageTitle: '乐跑成功',
  176. headline: '✅ 乐跑成功通知',
  177. variant: 'success',
  178. bodyHtml
  179. })
  180. )
  181. }
  182. async lepaoFail(email, data) {
  183. const time = new Date().getTime()
  184. const displayName = data.name ?? data.account
  185. const bodyHtml = [
  186. pEsc(`尊敬的 ${displayName}:`),
  187. pEsc('系统在尝试执行乐跑任务时未成功,详情如下:'),
  188. panel([
  189. kv('学号', data.account),
  190. kv('时间', this.stramptoTime(time)),
  191. kv('原因', data.reason)
  192. ].join('')),
  193. notice('若为登录失效,请在沐晨乐跑登录器中重新完成登录后再试。', 'warning'),
  194. pEsc('若问题持续存在,请联系沐晨乐跑客服并说明学号与失败时间,便于排查。', { last: true })
  195. ].join('')
  196. await sendEmail(
  197. email,
  198. '❌ 乐跑失败通知',
  199. renderEmail({
  200. pageTitle: '乐跑失败',
  201. headline: '❌ 乐跑未能完成',
  202. variant: 'danger',
  203. bodyHtml
  204. })
  205. )
  206. }
  207. async orderNewReply(email, data) {
  208. const files = Array.isArray(data.files) ? data.files : []
  209. const filesNote = files.length > 0
  210. ? notice('本回复包含附件,请登录沐晨乐跑官网,在工单详情中查看。', 'neutral')
  211. : ''
  212. const bodyHtml = [
  213. pEsc('尊敬的用户:'),
  214. pEsc(`您提交的工单(编号 ${data.id})有新的回复:`),
  215. panel([
  216. '<p><strong>回复内容:</strong></p>',
  217. `<p style="margin-top:8px;line-height:1.55;">${escapeNl2br(data.content)}</p>`,
  218. kv('回复时间', this.stramptoTime(new Date().getTime()))
  219. ].join('')),
  220. filesNote,
  221. notice('请登录沐晨乐跑官网,在工单详情中回复。', 'warning'),
  222. pEsc('感谢您的理解与配合。', { last: true })
  223. ].join('')
  224. await sendEmail(
  225. email,
  226. '💬 工单新回复',
  227. renderEmail({
  228. pageTitle: '工单状态更新',
  229. headline: '💬 工单有新回复',
  230. variant: 'default',
  231. bodyHtml
  232. })
  233. )
  234. }
  235. async sendCountRequestNotifyAdmins(email, data) {
  236. const bodyHtml = [
  237. pEsc('尊敬的管理员:'),
  238. pEsc('系统收到一条新的「赠送次数」申请,请及时登录管理后台审核处理。'),
  239. panel([
  240. kv('申请 ID', String(data.requestId)),
  241. kv('赠送人', data.senderUsername),
  242. kv('接收人', data.receiverUsername),
  243. kv('赠送次数', String(data.count)),
  244. kv('申请时间', this.stramptoTime(data.createTime))
  245. ].join('')),
  246. notice('请前往沐晨乐跑管理后台完成审核。', 'warning'),
  247. pEsc('谢谢。', { last: true })
  248. ].join('')
  249. await sendEmail(
  250. email,
  251. '⏳ 赠送次数待审核',
  252. renderEmail({
  253. pageTitle: '赠送次数待审核',
  254. headline: '⏳ 赠送次数待审核',
  255. variant: 'warning',
  256. bodyHtml
  257. })
  258. )
  259. }
  260. async lepaoAdminWarning(email, data) {
  261. const time = new Date().getTime()
  262. const safe = (v) => (v === undefined || v === null) ? '' : String(v)
  263. const subject = '🚨 乐跑异常告警'
  264. const bodyHtml = [
  265. pEsc('尊敬的管理员:'),
  266. pEsc('自动乐跑流程中出现非常见错误,请关注是否为接口变更、网络异常或程序缺陷,并及时跟进。'),
  267. panel([
  268. kv('账号', safe(data.account)),
  269. kv('姓名', safe(data.name)),
  270. kv('任务', safe(data.taskType)),
  271. kv('TraceId', safe(data.traceId)),
  272. kv('时间', this.stramptoTime(time)),
  273. kv('错误信息', safe(data.reason)),
  274. kv('错误码', safe(data.code))
  275. ].join('')),
  276. pEsc('以上为系统自动告警,如需更多信息请结合服务端日志进一步排查。', { last: true })
  277. ].join('')
  278. await sendEmail(
  279. email,
  280. subject,
  281. renderEmail({
  282. pageTitle: '乐跑异常告警',
  283. headline: '🚨 乐跑异常告警',
  284. headlineSuffixHtml: '<span class="tag">非常见错误</span>',
  285. variant: 'danger',
  286. bodyHtml
  287. })
  288. )
  289. }
  290. async sendCountRequestApproved(email, data) {
  291. const bodyHtml = [
  292. pEsc('尊敬的用户:'),
  293. pEsc('您收到的一笔赠送次数申请已通过审核,次数已到账:'),
  294. panel([
  295. kv('赠送人', data.senderUsername),
  296. kv('到账次数', String(data.count)),
  297. kv('审核时间', this.stramptoTime(data.reviewTime))
  298. ].join('')),
  299. pEsc('您可在沐晨乐跑中查看账户余额与明细。', { last: true })
  300. ].join('')
  301. await sendEmail(
  302. email,
  303. '✅ 赠送次数审核通过',
  304. renderEmail({
  305. pageTitle: '赠送次数审核通过',
  306. headline: '✅ 赠送次数已到账',
  307. variant: 'success',
  308. bodyHtml
  309. })
  310. )
  311. }
  312. async sendCountRequestRejected(email, data) {
  313. const bodyHtml = [
  314. pEsc('尊敬的用户:'),
  315. pEsc('您发起的一笔赠送次数申请未通过审核,相关次数已退回至您的账户:'),
  316. panel([
  317. kv('接收人', data.receiverUsername),
  318. kv('退回次数', String(data.count)),
  319. kv('审核时间', this.stramptoTime(data.reviewTime)),
  320. kv('拒绝原因', data.rejectReason)
  321. ].join('')),
  322. notice('如有疑问,请通过沐晨乐跑联系客服处理。', 'neutral'),
  323. pEsc('感谢您的理解。', { last: true })
  324. ].join('')
  325. await sendEmail(
  326. email,
  327. '⚠️ 赠送次数审核未通过',
  328. renderEmail({
  329. pageTitle: '赠送次数审核未通过',
  330. headline: '⚠️ 赠送次数审核未通过',
  331. variant: 'warning',
  332. bodyHtml
  333. })
  334. )
  335. }
  336. async powerCheck(email, data) {
  337. const gap = Number(data.lowest) - Number(data.now_balance)
  338. const gapStr = Number.isFinite(gap) ? gap.toFixed(2) : ''
  339. const bodyHtml = [
  340. pEsc('尊敬的用户:'),
  341. pEsc(`${data.building}${data.room} 宿舍电费已低于您设定的提醒阈值,请关注余额并及时充值。`),
  342. panel([
  343. kv('校区', data.area),
  344. kv('楼栋', data.building),
  345. kv('寝室号', data.room),
  346. kv('当前余额', `¥${data.now_balance}`),
  347. kv('提醒阈值', `¥${data.lowest}`),
  348. kv('最近扣费时间', data.now_change_time)
  349. ].join('')),
  350. notice(
  351. gapStr !== ''
  352. ? `当前余额已低于提醒阈值 ${gapStr} 元,建议尽快充值以免影响用电。`
  353. : '当前余额已低于提醒阈值,建议尽快充值以免影响用电。',
  354. 'warning'
  355. ),
  356. pEsc('您可在沐晨乐跑中调整提醒阈值或管理电费订阅。', { last: true })
  357. ].join('')
  358. await sendEmail(
  359. email,
  360. '⚡ 宿舍电费提醒',
  361. renderEmail({
  362. pageTitle: '宿舍电费提醒',
  363. headline: '⚡ 宿舍电费余额提醒',
  364. variant: 'warning',
  365. bodyHtml
  366. })
  367. )
  368. }
  369. formatSecondsToMinSec(totalSeconds) {
  370. const minutes = Math.floor(totalSeconds / 60)
  371. const seconds = totalSeconds % 60
  372. return `${minutes}分${seconds.toString().padStart(2, '0')}秒`
  373. }
  374. calculatePace(seconds, kilometers) {
  375. const paceInSeconds = seconds / kilometers
  376. const minutes = Math.floor(paceInSeconds / 60)
  377. const remainingSeconds = Math.round(paceInSeconds % 60)
  378. return `${minutes}'${remainingSeconds.toString().padStart(2, '0')}''`
  379. }
  380. }
  381. const EmailTemplate = new emailTemplate()
  382. module.exports = EmailTemplate