app.js 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517
  1. const state = {
  2. me: null,
  3. config: null,
  4. smtpCredential: null,
  5. dnsCredentials: [],
  6. apiTokens: [],
  7. domains: [],
  8. events: [],
  9. analytics: null,
  10. users: [],
  11. settings: null,
  12. selectedId: null,
  13. view: 'dashboard',
  14. detailTab: 'dns',
  15. dnsTestResults: {},
  16. busy: false
  17. };
  18. const viewMeta = {
  19. dashboard: {
  20. eyebrow: 'Command Center',
  21. title: '发信控制台',
  22. subtitle: '总览域名健康、发信趋势和关键配置状态。'
  23. },
  24. analytics: {
  25. eyebrow: 'Analytics',
  26. title: '统计分析',
  27. subtitle: '按时间、域名和状态分析发信表现。'
  28. },
  29. domains: {
  30. eyebrow: 'Domains',
  31. title: '发信域名',
  32. subtitle: '管理发信域名、DNS 检查和测试发送。'
  33. },
  34. dns: {
  35. eyebrow: 'DNS API',
  36. title: 'DNS API 凭据',
  37. subtitle: '按服务商保存多组 DNS 凭据,再绑定到具体域名。'
  38. },
  39. smtp: {
  40. eyebrow: 'SMTP',
  41. title: 'SMTP 凭据',
  42. subtitle: '生成可复制的 SMTP 用户名和密码。'
  43. },
  44. tokens: {
  45. eyebrow: 'API Tokens',
  46. title: '发送 API Token',
  47. subtitle: '为不同系统创建独立发信 Token。'
  48. },
  49. admin: {
  50. eyebrow: 'Admin',
  51. title: '系统设置',
  52. subtitle: '管理员设置默认发信参数和用户状态。'
  53. }
  54. };
  55. const els = {
  56. runtimeLine: document.querySelector('#runtimeLine'),
  57. viewEyebrow: document.querySelector('#viewEyebrow'),
  58. viewTitle: document.querySelector('#viewTitle'),
  59. securityNotice: document.querySelector('#securityNotice'),
  60. dashboardPanel: document.querySelector('#dashboardPanel'),
  61. analyticsPanel: document.querySelector('#analyticsPanel'),
  62. accountBox: document.querySelector('#accountBox'),
  63. userRole: document.querySelector('#userRole'),
  64. navButtons: document.querySelectorAll('.primary-nav [data-view]'),
  65. viewPanels: document.querySelectorAll('[data-view-panel]'),
  66. adminNavButton: document.querySelector('#adminNavButton'),
  67. addDomainForm: document.querySelector('#addDomainForm'),
  68. domainDnsCredential: document.querySelector('#domainDnsCredential'),
  69. domainReadiness: document.querySelector('#domainReadiness'),
  70. smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
  71. smtpCredentialState: document.querySelector('#smtpCredentialState'),
  72. smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
  73. smtpUsername: document.querySelector('#smtpUsername'),
  74. smtpPassword: document.querySelector('#smtpPassword'),
  75. generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
  76. dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
  77. dnsCredentialFormTitle: document.querySelector('#dnsCredentialFormTitle'),
  78. dnsCredentialFormHint: document.querySelector('#dnsCredentialFormHint'),
  79. dnsCredentialIdField: document.querySelector('#dnsCredentialIdField'),
  80. dnsCredentialName: document.querySelector('#dnsCredentialName'),
  81. dnsDefaultTtl: document.querySelector('#dnsDefaultTtl'),
  82. dnsProvider: document.querySelector('#dnsProvider'),
  83. dnsZoneName: document.querySelector('#dnsZoneName'),
  84. dnsCredentialList: document.querySelector('#dnsCredentialList'),
  85. dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
  86. resetDnsCredentialForm: document.querySelector('#resetDnsCredentialForm'),
  87. apiTokenForm: document.querySelector('#apiTokenForm'),
  88. apiTokenList: document.querySelector('#apiTokenList'),
  89. apiTokenCount: document.querySelector('#apiTokenCount'),
  90. analyticsEventCount: document.querySelector('#analyticsEventCount'),
  91. defaultSenderHost: document.querySelector('#defaultSenderHost'),
  92. defaultSendingIp: document.querySelector('#defaultSendingIp'),
  93. defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
  94. domainList: document.querySelector('#domainList'),
  95. domainCount: document.querySelector('#domainCount'),
  96. detailPanel: document.querySelector('#detailPanel'),
  97. adminPanel: document.querySelector('#adminPanel'),
  98. refreshButton: document.querySelector('#refreshButton'),
  99. refreshButtonMobile: document.querySelector('#refreshButtonMobile'),
  100. logoutButton: document.querySelector('#logoutButton'),
  101. toastHost: document.querySelector('#toastHost')
  102. };
  103. init();
  104. async function init() {
  105. bindEvents();
  106. await refreshAll();
  107. }
  108. function bindEvents() {
  109. els.refreshButton.addEventListener('click', refreshAll);
  110. els.refreshButtonMobile?.addEventListener('click', refreshAll);
  111. els.logoutButton.addEventListener('click', logout);
  112. document.addEventListener('click', handleGlobalClick);
  113. els.addDomainForm.addEventListener('submit', addDomain);
  114. els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
  115. els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
  116. els.dnsCredentialForm.addEventListener('submit', saveDnsCredential);
  117. els.dnsProvider.addEventListener('change', toggleDnsProviderFields);
  118. els.resetDnsCredentialForm.addEventListener('click', resetDnsCredentialForm);
  119. els.apiTokenForm.addEventListener('submit', createApiToken);
  120. els.domainList.addEventListener('click', (event) => {
  121. const item = event.target.closest('[data-domain-id]');
  122. if (!item) return;
  123. state.selectedId = Number(item.dataset.domainId);
  124. state.detailTab = 'dns';
  125. setView('domains');
  126. render();
  127. });
  128. els.detailPanel.addEventListener('submit', handleDetailSubmit);
  129. els.adminPanel.addEventListener('submit', handleDetailSubmit);
  130. }
  131. async function refreshAll() {
  132. setBusy(true);
  133. try {
  134. const me = await api('/api/me');
  135. state.me = me.user;
  136. const baseCalls = [
  137. api('/api/config'),
  138. api('/api/domains'),
  139. api('/api/events'),
  140. api('/api/analytics?days=7'),
  141. api('/api/smtp-credential'),
  142. api('/api/dns-credentials'),
  143. api('/api/api-tokens')
  144. ];
  145. const [config, domains, events, analytics, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
  146. state.config = config;
  147. state.domains = domains.domains || [];
  148. state.events = events.events || [];
  149. state.analytics = analytics.analytics || null;
  150. state.smtpCredential = smtpCredential.credential || null;
  151. state.dnsCredentials = dnsCredentials.credentials || [];
  152. state.apiTokens = apiTokens.tokens || [];
  153. if (state.me?.role === 'admin') {
  154. const [settings, users] = await Promise.all([
  155. api('/api/admin/settings'),
  156. api('/api/admin/users')
  157. ]);
  158. state.settings = settings.settings || null;
  159. state.users = users.users || [];
  160. }
  161. if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
  162. if (state.selectedId && !state.domains.some((domain) => domain.id === state.selectedId)) {
  163. state.selectedId = state.domains[0]?.id || null;
  164. }
  165. renderDefaults();
  166. render();
  167. } catch (error) {
  168. toast(error.message, 'error');
  169. } finally {
  170. setBusy(false);
  171. }
  172. }
  173. function renderDefaults() {
  174. if (!state.config) return;
  175. const ip = state.config.sendingIp || '未设置发信 IP';
  176. els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${viewMeta[state.view]?.subtitle || ''}`;
  177. els.defaultSenderHost.value = state.config.mailHostname || '';
  178. els.defaultSendingIp.value = state.config.sendingIp || '';
  179. els.defaultSpfExtra.value = state.config.defaultSpfMechanisms || '';
  180. els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || '';
  181. els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置';
  182. els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户';
  183. els.userRole.className = `badge ${state.me?.role === 'admin' ? 'bg-green-lt' : 'bg-secondary-lt'}`;
  184. els.adminNavButton.classList.toggle('hidden', state.me?.role !== 'admin');
  185. if (state.me?.role !== 'admin' && state.view === 'admin') setView('domains');
  186. els.accountBox.innerHTML = `
  187. <div class="list-group-item px-0 py-2 bg-transparent border-0">
  188. <div class="text-secondary small">Username</div>
  189. <code>${escapeHtml(state.me?.username || '')}</code>
  190. </div>
  191. <div class="list-group-item px-0 py-2 bg-transparent border-0">
  192. <div class="text-secondary small">Email</div>
  193. <code>${escapeHtml(state.me?.email || '')}</code>
  194. </div>
  195. `;
  196. renderDnsOptions();
  197. renderDashboard();
  198. renderAnalyticsPanel();
  199. renderDomainReadiness();
  200. renderSmtpCredentialCopy();
  201. renderDnsCredentials();
  202. renderApiTokens();
  203. renderAdminPanel();
  204. renderSecurityNotice();
  205. toggleDnsProviderFields();
  206. }
  207. function render() {
  208. els.domainCount.textContent = String(state.domains.length);
  209. els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
  210. els.apiTokenCount.textContent = String(state.apiTokens.length);
  211. els.analyticsEventCount.textContent = String(state.analytics?.summary?.total || 0);
  212. renderViewMeta();
  213. renderDashboard();
  214. renderAnalyticsPanel();
  215. renderDomainList();
  216. renderDetail();
  217. }
  218. function renderViewMeta() {
  219. const meta = viewMeta[state.view] || viewMeta.domains;
  220. els.viewEyebrow.textContent = meta.eyebrow;
  221. els.viewTitle.textContent = meta.title;
  222. if (state.config) {
  223. const ip = state.config.sendingIp || '未设置发信 IP';
  224. els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${meta.subtitle}`;
  225. }
  226. for (const button of els.navButtons) {
  227. button.classList.toggle('active', button.dataset.view === state.view);
  228. }
  229. for (const panel of els.viewPanels) {
  230. panel.classList.toggle('active', panel.dataset.viewPanel === state.view);
  231. }
  232. }
  233. function setView(view) {
  234. if (!viewMeta[view]) return;
  235. if (view === 'admin' && state.me?.role !== 'admin') return;
  236. state.view = view;
  237. renderViewMeta();
  238. }
  239. function renderSecurityNotice() {
  240. els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
  241. els.securityNotice.textContent = state.config.usingDefaultAdminPassword
  242. ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
  243. : '';
  244. }
  245. function renderDashboard() {
  246. if (!els.dashboardPanel) return;
  247. const analytics = state.analytics || {};
  248. const summary = analytics.summary || {};
  249. const totalDomains = state.domains.length;
  250. const verifiedDomains = state.domains.filter((domain) => domain.status?.verified).length;
  251. const dnsReady = state.dnsCredentials.length > 0;
  252. const smtpReady = Boolean(state.smtpCredential?.passwordSet);
  253. const tokenReady = state.apiTokens.length > 0;
  254. const domainScore = totalDomains ? (verifiedDomains / totalDomains) * 42 : 0;
  255. const score = Math.min(100, Math.round(domainScore + (dnsReady ? 18 : 0) + (smtpReady ? 18 : 0) + (tokenReady ? 8 : 0) + ((summary.successRate || 0) * 0.14)));
  256. const risks = dashboardRisks(summary, { totalDomains, verifiedDomains, dnsReady, smtpReady, tokenReady });
  257. els.dashboardPanel.innerHTML = `
  258. <section class="console-hero">
  259. <div class="console-copy">
  260. <span class="eyebrow">MailHub Command Center</span>
  261. <h2>多租户发信运营后台</h2>
  262. <p>${escapeHtml(state.config?.mailHostname || 'mail host')} · ${escapeHtml(state.config?.sendingIp || '未设置发信 IP')} · ${escapeHtml(state.me?.email || '')}</p>
  263. <div class="hero-actions">
  264. <button class="btn btn-primary" data-view="domains" type="button"><i class="ti ti-world-plus"></i> 添加域名</button>
  265. <button class="btn btn-outline-secondary" data-view="analytics" type="button"><i class="ti ti-chart-histogram"></i> 查看分析</button>
  266. </div>
  267. </div>
  268. <div class="health-orbit" style="--score: ${score}%">
  269. <span>健康评分</span>
  270. <strong>${score}</strong>
  271. <small>${score >= 90 ? '运行良好' : score >= 70 ? '仍有优化项' : '需要配置'}</small>
  272. </div>
  273. </section>
  274. <section class="ops-grid">
  275. ${kpiTile('今日发送', summary.today || 0, `${summary.last7Days || 0} / 近 7 日`, 'ti-send', 'cyan')}
  276. ${kpiTile('成功率', `${summary.successRate || 0}%`, `${summary.queued || 0} 成功 / ${summary.failed || 0} 失败`, 'ti-shield-check', 'green')}
  277. ${kpiTile('域名健康', `${verifiedDomains}/${totalDomains}`, '已验证发信域名', 'ti-world-check', 'blue')}
  278. ${kpiTile('收件人触达', summary.recipients || 0, '统计窗口内收件人数', 'ti-users', 'amber')}
  279. </section>
  280. <section class="dashboard-layout">
  281. <div class="data-panel trend-panel">
  282. <div class="panel-heading">
  283. <div>
  284. <span class="eyebrow">Traffic</span>
  285. <h3>近 14 日发信趋势</h3>
  286. </div>
  287. <span class="badge bg-cyan-lt">${summary.total || 0} events</span>
  288. </div>
  289. ${renderDailyBars((analytics.byDay || []).slice(-14))}
  290. </div>
  291. <div class="data-panel">
  292. <div class="panel-heading">
  293. <div>
  294. <span class="eyebrow">Readiness</span>
  295. <h3>上线检查</h3>
  296. </div>
  297. </div>
  298. <div class="readiness-list">
  299. ${readinessItem('DNS API', dnsReady, dnsReady ? `${state.dnsCredentials.length} 组可用` : '需要添加 DNS API')}
  300. ${readinessItem('SMTP 凭据', smtpReady, smtpReady ? state.smtpCredential.username : '需要设置平台凭据')}
  301. ${readinessItem('发信域名', Boolean(totalDomains), totalDomains ? `${verifiedDomains} 个已验证` : '需要添加域名')}
  302. ${readinessItem('API Token', tokenReady, tokenReady ? `${state.apiTokens.length} 个 Token` : '建议创建发送 Token')}
  303. </div>
  304. </div>
  305. <div class="data-panel">
  306. <div class="panel-heading">
  307. <div>
  308. <span class="eyebrow">Focus</span>
  309. <h3>待处理事项</h3>
  310. </div>
  311. </div>
  312. ${renderRiskList(risks)}
  313. </div>
  314. </section>
  315. `;
  316. }
  317. function renderAnalyticsPanel() {
  318. if (!els.analyticsPanel) return;
  319. const analytics = state.analytics || {};
  320. const summary = analytics.summary || {};
  321. const statusTotal = Math.max(1, summary.total || 0);
  322. const queuedRatio = Math.round(((summary.queued || 0) / statusTotal) * 100);
  323. els.analyticsPanel.innerHTML = `
  324. <section class="analysis-head">
  325. <div>
  326. <span class="eyebrow">Analytics Window</span>
  327. <h2>${analytics.windowDays || 30} 天发信分析</h2>
  328. <p>${escapeHtml(state.me?.username || '')} 的独立数据视图,按用户隔离统计。</p>
  329. </div>
  330. <div class="analysis-summary">
  331. ${miniMetric('总发送', summary.total || 0)}
  332. ${miniMetric('今日', summary.today || 0)}
  333. ${miniMetric('成功率', `${summary.successRate || 0}%`)}
  334. </div>
  335. </section>
  336. <section class="analysis-grid">
  337. <div class="data-panel analysis-wide">
  338. <div class="panel-heading">
  339. <div><span class="eyebrow">Daily Volume</span><h3>每日发送量</h3></div>
  340. <span class="badge bg-blue-lt">${summary.recipients || 0} recipients</span>
  341. </div>
  342. ${renderDailyBars(analytics.byDay || [])}
  343. </div>
  344. <div class="data-panel">
  345. <div class="panel-heading">
  346. <div><span class="eyebrow">Status</span><h3>状态分布</h3></div>
  347. </div>
  348. <div class="status-donut" style="--queued: ${queuedRatio}%">
  349. <strong>${queuedRatio}%</strong>
  350. <span>queued</span>
  351. </div>
  352. <div class="status-legend">
  353. ${(analytics.byStatus || []).map((item) => `<div><span>${escapeHtml(statusLabel(item.status))}</span><strong>${escapeHtml(item.total)}</strong></div>`).join('') || '<p class="text-secondary">暂无发送数据</p>'}
  354. </div>
  355. </div>
  356. <div class="data-panel">
  357. <div class="panel-heading">
  358. <div><span class="eyebrow">Domains</span><h3>域名排行</h3></div>
  359. </div>
  360. ${renderDomainLeaderboard(analytics.byDomain || [])}
  361. </div>
  362. <div class="data-panel">
  363. <div class="panel-heading">
  364. <div><span class="eyebrow">Hour Map</span><h3>24 小时热力</h3></div>
  365. </div>
  366. ${renderHourlyHeatmap(analytics.hourly || [])}
  367. </div>
  368. <div class="data-panel analysis-wide">
  369. <div class="panel-heading">
  370. <div><span class="eyebrow">Failures</span><h3>最近失败</h3></div>
  371. </div>
  372. ${renderFailureList(analytics.recentFailures || [])}
  373. </div>
  374. </section>
  375. `;
  376. }
  377. function dashboardRisks(summary, flags) {
  378. const risks = [];
  379. if (!flags.smtpReady) risks.push({ tone: 'failed', title: 'SMTP 凭据未配置', detail: '客户端无法完成 SMTP Submission 认证。' });
  380. if (!flags.dnsReady) risks.push({ tone: 'warn', title: 'DNS API 未配置', detail: '域名需要手动复制 DNS 记录。' });
  381. if (flags.totalDomains && flags.verifiedDomains < flags.totalDomains) risks.push({ tone: 'warn', title: '存在未验证域名', detail: `${flags.totalDomains - flags.verifiedDomains} 个域名仍需检查 DNS。` });
  382. if ((summary.failed || 0) > 0) risks.push({ tone: 'failed', title: '近期有发送失败', detail: `${summary.failed} 条失败记录,建议查看失败原因。` });
  383. if (!flags.tokenReady) risks.push({ tone: 'idle', title: '未创建 API Token', detail: '生产系统建议使用独立 Token 接入。' });
  384. return risks;
  385. }
  386. function renderRiskList(risks) {
  387. if (!risks.length) return '<div class="empty-soft">暂无待处理事项</div>';
  388. return `<div class="risk-list">${risks.map((risk) => `
  389. <div class="risk-row ${risk.tone}">
  390. ${badge({ className: risk.tone, label: risk.tone === 'failed' ? '重点' : risk.tone === 'warn' ? '注意' : '建议' })}
  391. <div><strong>${escapeHtml(risk.title)}</strong><span>${escapeHtml(risk.detail)}</span></div>
  392. </div>
  393. `).join('')}</div>`;
  394. }
  395. function kpiTile(label, value, hint, icon, tone) {
  396. return `
  397. <article class="kpi-tile ${tone}">
  398. <i class="ti ${icon}" aria-hidden="true"></i>
  399. <span>${escapeHtml(label)}</span>
  400. <strong>${escapeHtml(value)}</strong>
  401. <small>${escapeHtml(hint || '')}</small>
  402. </article>
  403. `;
  404. }
  405. function miniMetric(label, value) {
  406. return `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
  407. }
  408. function readinessItem(label, ready, detail) {
  409. return `
  410. <div class="readiness-item ${ready ? 'ok' : 'warn'}">
  411. <i class="ti ${ready ? 'ti-circle-check' : 'ti-alert-triangle'}" aria-hidden="true"></i>
  412. <div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div>
  413. </div>
  414. `;
  415. }
  416. function renderDailyBars(days) {
  417. if (!days.length || !days.some((day) => day.total)) return '<div class="empty-soft">暂无发送数据</div>';
  418. const max = Math.max(...days.map((day) => day.total), 1);
  419. return `
  420. <div class="bar-chart">
  421. ${days.map((day) => {
  422. const totalHeight = Math.max(4, Math.round((day.total / max) * 100));
  423. const queuedHeight = day.total ? Math.round((day.queued / day.total) * totalHeight) : 0;
  424. const failedHeight = Math.max(0, totalHeight - queuedHeight);
  425. return `
  426. <div class="bar-column" title="${escapeAttr(day.day)} · ${escapeAttr(day.total)}">
  427. <div class="bar-track">
  428. <span class="bar-failed" style="height:${failedHeight}%"></span>
  429. <span class="bar-queued" style="height:${queuedHeight}%"></span>
  430. </div>
  431. <small>${escapeHtml(day.day.slice(5))}</small>
  432. </div>
  433. `;
  434. }).join('')}
  435. </div>
  436. `;
  437. }
  438. function renderDomainLeaderboard(rows) {
  439. if (!rows.length) return '<div class="empty-soft">暂无域名发送数据</div>';
  440. const max = Math.max(...rows.map((row) => row.total), 1);
  441. return `<div class="leaderboard">${rows.map((row) => `
  442. <div class="leader-row">
  443. <div><strong>${escapeHtml(row.domain)}</strong><span>${escapeHtml(row.queued)} 成功 / ${escapeHtml(row.failed)} 失败</span></div>
  444. <div class="leader-meter"><span style="width:${Math.round((row.total / max) * 100)}%"></span></div>
  445. <b>${escapeHtml(row.total)}</b>
  446. </div>
  447. `).join('')}</div>`;
  448. }
  449. function renderHourlyHeatmap(rows) {
  450. const max = Math.max(...rows.map((row) => row.total), 1);
  451. return `<div class="hour-grid">${rows.map((row) => {
  452. const level = row.total ? Math.max(1, Math.ceil((row.total / max) * 4)) : 0;
  453. return `<span class="hour-cell level-${level}" title="${row.hour}:00 · ${row.total}">${String(row.hour).padStart(2, '0')}</span>`;
  454. }).join('')}</div>`;
  455. }
  456. function renderFailureList(rows) {
  457. if (!rows.length) return '<div class="empty-soft">暂无失败记录</div>';
  458. return `<div class="failure-list">${rows.map((row) => `
  459. <div class="failure-row">
  460. <div><strong>${escapeHtml(row.subject || '(no subject)')}</strong><span>${escapeHtml(row.sender)} · ${escapeHtml(formatDate(row.createdAt))}</span></div>
  461. <code>${escapeHtml(row.detail || '未返回错误详情')}</code>
  462. </div>
  463. `).join('')}</div>`;
  464. }
  465. function renderDomainReadiness() {
  466. const verified = state.domains.filter((domain) => domain.status?.verified).length;
  467. const latestEvent = state.events[0];
  468. els.domainReadiness.innerHTML = `
  469. <div class="card-header">
  470. <div>
  471. <h3 class="card-title">运行概览</h3>
  472. <p class="card-subtitle">关键配置集中在这里,缺哪一项就先补哪一项。</p>
  473. </div>
  474. </div>
  475. <div class="card-body">
  476. <div class="row row-cards">
  477. ${metricCard('域名', state.domains.length, `${verified} 个已通过`)}
  478. ${metricCard('DNS API', state.dnsCredentials.length, state.dnsCredentials.length ? '可一键写入' : '尚未配置')}
  479. ${metricCard('SMTP', state.smtpCredential?.passwordSet ? 'Ready' : 'Missing', state.smtpCredential?.username || '未设置凭据')}
  480. ${metricCard('最近发送', latestEvent ? formatDate(latestEvent.createdAt) : '暂无', latestEvent?.status || '无记录')}
  481. </div>
  482. <div class="d-flex flex-wrap gap-2 mt-3">
  483. ${!state.dnsCredentials.length ? '<button class="btn btn-outline-secondary" data-view="dns" type="button">新增 DNS API</button>' : ''}
  484. ${!state.smtpCredential?.passwordSet ? '<button class="btn btn-outline-secondary" data-view="smtp" type="button">设置 SMTP</button>' : ''}
  485. <button class="btn btn-outline-secondary" data-action="check" type="button" ${state.selectedId ? '' : 'disabled'}>检查当前域名</button>
  486. </div>
  487. </div>
  488. `;
  489. }
  490. function metricCard(label, value, hint) {
  491. return `
  492. <div class="col-sm-6">
  493. <div class="card card-sm metric-card">
  494. <div class="card-body">
  495. <div class="text-secondary small">${escapeHtml(label)}</div>
  496. <div class="h2 m-0">${escapeHtml(value)}</div>
  497. <div class="text-secondary small">${escapeHtml(hint || '')}</div>
  498. </div>
  499. </div>
  500. </div>
  501. `;
  502. }
  503. function renderDnsOptions(selectedId = '') {
  504. const activeId = selectedId || els.domainDnsCredential.value || '';
  505. if (!state.dnsCredentials.length) {
  506. els.domainDnsCredential.innerHTML = '<option value="">未绑定,先手动配置或新增 DNS API</option>';
  507. return;
  508. }
  509. const options = [
  510. '<option value="">不绑定,手动配置</option>',
  511. ...state.dnsCredentials.map((credential) => `
  512. <option value="${credential.id}" ${String(activeId) === String(credential.id) ? 'selected' : ''}>
  513. ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}
  514. </option>
  515. `)
  516. ].join('');
  517. els.domainDnsCredential.innerHTML = options;
  518. }
  519. function renderSmtpCredentialCopy() {
  520. const credential = state.smtpCredential;
  521. if (!credential?.username && !credential?.passwordSet) {
  522. els.smtpCredentialCopy.innerHTML = `
  523. <div class="empty">
  524. <div class="empty-title">还没有 SMTP 凭据</div>
  525. <p>保存用户名和密码后,这里会显示可复制内容。</p>
  526. </div>
  527. `;
  528. return;
  529. }
  530. const password = credential?.password || '';
  531. els.smtpCredentialCopy.innerHTML = `
  532. <div class="copy-row">
  533. <span>Username</span>
  534. <code>${escapeHtml(credential.username || '')}</code>
  535. <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
  536. </div>
  537. <div class="copy-row">
  538. <span>Password</span>
  539. ${password
  540. ? `<code>${escapeHtml(password)}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(password)}" type="button">复制</button>`
  541. : '<p class="text-secondary">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
  542. </div>
  543. `;
  544. }
  545. function renderDnsCredentials() {
  546. els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
  547. if (!state.dnsCredentials.length) {
  548. els.dnsCredentialList.innerHTML = `
  549. <div class="empty">
  550. <div class="empty-title">暂无 DNS API 凭据</div>
  551. <p>先在右侧新增一组凭据;添加域名时下拉框就会出现可选项。</p>
  552. </div>
  553. `;
  554. return;
  555. }
  556. els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
  557. <article class="card credential-row">
  558. <div class="card-body">
  559. <div>
  560. <div class="d-flex align-items-center justify-content-between gap-2 mb-1">
  561. <strong>${escapeHtml(credential.name)}</strong>
  562. ${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
  563. </div>
  564. <span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
  565. ${renderDnsTestResult(credential.id)}
  566. </div>
  567. <div class="d-flex flex-wrap gap-2 justify-content-end">
  568. <button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
  569. <button class="btn btn-sm btn-outline-secondary" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
  570. <button class="btn btn-sm btn-outline-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
  571. </div>
  572. </div>
  573. </article>
  574. `).join('');
  575. }
  576. function renderDnsTestResult(id) {
  577. const result = state.dnsTestResults[id];
  578. if (!result) return '';
  579. return `
  580. <div class="dns-test-result ${result.ok ? 'ok' : 'failed'}">
  581. <i class="ti ${result.ok ? 'ti-plug-connected' : 'ti-plug-connected-x'}" aria-hidden="true"></i>
  582. <span>${escapeHtml(result.message)}</span>
  583. </div>
  584. `;
  585. }
  586. function renderApiTokens() {
  587. els.apiTokenCount.textContent = String(state.apiTokens.length);
  588. if (!state.apiTokens.length) {
  589. els.apiTokenList.innerHTML = `
  590. <div class="empty">
  591. <div class="empty-title">暂无发送 Token</div>
  592. <p>创建后请立即保存完整 Token,页面之后只显示前缀。</p>
  593. </div>
  594. `;
  595. return;
  596. }
  597. els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
  598. <div class="mini-row">
  599. <div>
  600. <strong>${escapeHtml(token.name)}</strong>
  601. <span class="text-secondary">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
  602. </div>
  603. <button class="btn btn-sm btn-outline-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
  604. </div>
  605. `).join('');
  606. }
  607. function renderAdminPanel() {
  608. if (state.me?.role !== 'admin') {
  609. els.adminPanel.classList.add('hidden');
  610. els.adminPanel.innerHTML = '';
  611. return;
  612. }
  613. const settings = state.settings || state.config || {};
  614. els.adminPanel.classList.remove('hidden');
  615. els.adminPanel.innerHTML = `
  616. <div class="card-header">
  617. <div>
  618. <h3 class="card-title">系统默认值</h3>
  619. <p class="card-subtitle">这些值会作为新域名的默认 DNS 和发信参数。</p>
  620. </div>
  621. <div class="card-actions">
  622. <span class="badge bg-blue-lt">${state.users.length} 用户</span>
  623. </div>
  624. </div>
  625. <div class="card-body">
  626. <form class="compact-form" data-form="admin-settings">
  627. <div class="mb-3">
  628. <label class="form-label">APP Base URL</label>
  629. <input class="form-control" name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}">
  630. <div class="form-hint">API 示例和外部访问地址。</div>
  631. </div>
  632. <div class="row">
  633. <div class="col-sm-6 mb-3">
  634. <label class="form-label">发信主机</label>
  635. <input class="form-control" name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}">
  636. <div class="form-hint">建议使用 mail.example.com。</div>
  637. </div>
  638. <div class="col-sm-6 mb-3">
  639. <label class="form-label">发信 IP</label>
  640. <input class="form-control" name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}">
  641. <div class="form-hint">服务器公网 IPv4。</div>
  642. </div>
  643. </div>
  644. <div class="mb-3">
  645. <label class="form-label">默认 SPF</label>
  646. <textarea class="form-control" name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea>
  647. <div class="form-hint">会合并到 v=spf1 中,不要重复写 v=spf1。</div>
  648. </div>
  649. <div class="row">
  650. <div class="col-sm-6 mb-3">
  651. <label class="form-label">DMARC</label>
  652. <select class="form-select" name="dmarcPolicy">
  653. ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
  654. </select>
  655. </div>
  656. <div class="col-sm-6 mb-3">
  657. <label class="form-label">验证后发信</label>
  658. <select class="form-select" name="sendRequiresVerified">
  659. <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
  660. <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
  661. </select>
  662. </div>
  663. </div>
  664. <div class="mb-3">
  665. <label class="form-label">DMARC rua</label>
  666. <input class="form-control" name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
  667. </div>
  668. <button class="btn btn-primary" type="submit">保存系统设置</button>
  669. </form>
  670. </div>
  671. <div class="card-header border-top">
  672. <div>
  673. <h3 class="card-title">用户状态</h3>
  674. <p class="card-subtitle">禁用用户后,该用户的控制台、API Token 和 SMTP 认证都会失效。</p>
  675. </div>
  676. </div>
  677. <div class="card-body">
  678. <div class="mini-list user-mini-list">
  679. ${state.users.slice(0, 12).map((user) => `
  680. <div class="mini-row">
  681. <div>
  682. <strong>${escapeHtml(user.username)}</strong>
  683. <span class="text-secondary">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
  684. </div>
  685. <button class="btn btn-sm ${user.status === 'active' ? 'btn-outline-danger' : 'btn-outline-secondary'}" data-action="toggle-user" data-id="${user.id}" data-status="${user.status}" type="button">
  686. ${user.status === 'active' ? '禁用' : '启用'}
  687. </button>
  688. </div>
  689. `).join('')}
  690. </div>
  691. </div>
  692. `;
  693. }
  694. function renderDomainList() {
  695. if (!state.domains.length) {
  696. els.domainList.innerHTML = '<div class="empty-mini">暂无域名</div>';
  697. return;
  698. }
  699. els.domainList.innerHTML = state.domains.map((domain) => {
  700. const status = statusMeta(domain.status);
  701. return `
  702. <button class="domain-item ${domain.id === state.selectedId ? 'active' : ''}" data-domain-id="${domain.id}" type="button">
  703. <span>${escapeHtml(domain.domain)}</span>
  704. ${badge(status)}
  705. <small>${escapeHtml(domain.selector)}._domainkey</small>
  706. </button>
  707. `;
  708. }).join('');
  709. }
  710. function renderDetail() {
  711. const domain = state.domains.find((item) => item.id === state.selectedId);
  712. if (!domain) {
  713. els.detailPanel.innerHTML = `
  714. <div class="empty-state">
  715. <h2>选择或添加一个域名</h2>
  716. <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
  717. </div>
  718. `;
  719. return;
  720. }
  721. const guide = domain.status || {};
  722. const records = guide.records || [];
  723. const warnings = guide.warnings || [];
  724. const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
  725. const applyDisabled = domain.dnsCredentialId ? '' : 'disabled';
  726. const applyTitle = domain.dnsCredentialId ? '一键配置 DNS' : '请先在域名设置里绑定 DNS API 凭据';
  727. els.detailPanel.innerHTML = `
  728. <div class="card-header detail-header">
  729. <div>
  730. <h3 class="card-title">${escapeHtml(domain.domain)}</h3>
  731. <div class="status-strip">
  732. ${badge(statusMeta(guide))}
  733. <span class="badge bg-secondary-lt">最近检查 ${escapeHtml(checkedAt)}</span>
  734. <span class="badge bg-secondary-lt">Selector ${escapeHtml(domain.selector)}</span>
  735. </div>
  736. </div>
  737. <div class="card-actions detail-actions">
  738. <button class="btn btn-primary" data-action="apply-dns" title="${escapeAttr(applyTitle)}" type="button" ${applyDisabled}>一键配置 DNS</button>
  739. <button class="btn btn-outline-secondary" data-action="check" type="button">立即检查</button>
  740. <button class="btn btn-outline-secondary" data-action="rotate-dkim" type="button">轮换 DKIM</button>
  741. <button class="btn btn-outline-danger" data-action="delete-domain" type="button">删除</button>
  742. </div>
  743. </div>
  744. <div class="card-body detail-body">
  745. ${renderSetupOverview(domain, guide, records)}
  746. ${warnings.length ? renderWarnings(warnings) : ''}
  747. ${renderDetailTabs()}
  748. ${renderDetailTabContent(domain, guide, records)}
  749. </div>
  750. `;
  751. }
  752. function renderDetailTabs() {
  753. const tabs = [
  754. ['dns', 'DNS 配置', 'ti-dns'],
  755. ['settings', '域名设置', 'ti-adjustments'],
  756. ['send', '测试发送', 'ti-send'],
  757. ['integrations', '接入方式', 'ti-plug-connected'],
  758. ['events', '发送记录', 'ti-list-details']
  759. ];
  760. return `
  761. <div class="detail-tabs" role="tablist">
  762. ${tabs.map(([key, label, icon]) => `
  763. <button class="${state.detailTab === key ? 'active' : ''}" data-action="detail-tab" data-tab="${key}" type="button">
  764. <i class="ti ${icon}" aria-hidden="true"></i>
  765. <span>${label}</span>
  766. </button>
  767. `).join('')}
  768. </div>
  769. `;
  770. }
  771. function renderDetailTabContent(domain, guide, records) {
  772. if (state.detailTab === 'settings') {
  773. return `
  774. <section class="detail-tab-panel">
  775. <div class="row row-cards">
  776. <section class="col-lg-7">
  777. <div class="card subpanel">
  778. <div class="card-header"><h3 class="card-title">域名设置</h3></div>
  779. <div class="card-body">${renderSettingsForm(domain)}</div>
  780. </div>
  781. </section>
  782. <section class="col-lg-5">
  783. <div class="card subpanel">
  784. <div class="card-header"><h3 class="card-title">增强安全记录</h3></div>
  785. <div class="card-body">${renderOptionalRecords(guide.optionalRecords || [])}</div>
  786. </div>
  787. </section>
  788. </div>
  789. </section>
  790. `;
  791. }
  792. if (state.detailTab === 'send') {
  793. return `
  794. <section class="detail-tab-panel">
  795. <div class="row row-cards">
  796. <section class="col-lg-5">
  797. <div class="card subpanel">
  798. <div class="card-header"><h3 class="card-title">测试发送</h3></div>
  799. <div class="card-body">${renderSendForm(domain)}</div>
  800. </div>
  801. </section>
  802. <section class="col-lg-7">
  803. <div class="card subpanel">
  804. <div class="card-header"><h3 class="card-title">最近发送</h3></div>
  805. <div class="card-body">${renderEvents(domain)}</div>
  806. </div>
  807. </section>
  808. </div>
  809. </section>
  810. `;
  811. }
  812. if (state.detailTab === 'integrations') {
  813. return `
  814. <section class="detail-tab-panel">
  815. <div class="row row-cards">
  816. <section class="col-lg-6">
  817. <div class="card subpanel api-box">
  818. <div class="card-header"><h3 class="card-title">发送 API</h3></div>
  819. <div class="card-body"><pre><code>${escapeHtml(apiExample(domain))}</code></pre></div>
  820. </div>
  821. </section>
  822. <section class="col-lg-6">
  823. <div class="card subpanel">
  824. <div class="card-header"><h3 class="card-title">SMTP 发信</h3></div>
  825. <div class="card-body">${renderSmtpBox(domain)}</div>
  826. </div>
  827. </section>
  828. </div>
  829. </section>
  830. `;
  831. }
  832. if (state.detailTab === 'events') {
  833. return `
  834. <section class="detail-tab-panel">
  835. <div class="card subpanel">
  836. <div class="card-header"><h3 class="card-title">发送记录</h3></div>
  837. <div class="card-body">${renderEvents(domain, 20)}</div>
  838. </div>
  839. </section>
  840. `;
  841. }
  842. return `
  843. <section class="detail-tab-panel">
  844. ${guide.apply ? renderApplyResult(guide.apply) : ''}
  845. <div class="row row-cards">
  846. <section class="col-lg-7">
  847. <div class="card subpanel">
  848. <div class="card-header">
  849. <h3 class="card-title">配置引导</h3>
  850. <div class="card-actions"><button class="btn btn-sm btn-outline-secondary" data-action="copy-all-dns" type="button">复制全部</button></div>
  851. </div>
  852. <div class="card-body">${renderDnsGuide(records)}</div>
  853. </div>
  854. </section>
  855. <section class="col-lg-5">
  856. <div class="card subpanel">
  857. <div class="card-header"><h3 class="card-title">当前 DNS</h3></div>
  858. <div class="card-body">${renderLiveDns(guide.live)}</div>
  859. </div>
  860. </section>
  861. </div>
  862. </section>
  863. `;
  864. }
  865. function renderSetupOverview(domain, guide, records) {
  866. const important = [['verification', '域名验证'], ['dkim', 'DKIM'], ['spf', 'SPF'], ['dmarc', 'DMARC'], ['ptr', 'PTR']];
  867. const cards = important.map(([key, label]) => {
  868. const record = records.find((item) => item.key === key);
  869. const meta = statusMeta(record || {});
  870. return `<div class="metric-card compact"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
  871. }).join('');
  872. const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
  873. return `
  874. <section class="guide-hero">
  875. <div>
  876. <span class="eyebrow">Sending domain</span>
  877. <h3>${escapeHtml(domain.domain)}</h3>
  878. <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp || '未设置 IP')}</p>
  879. <p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
  880. </div>
  881. <div class="summary-grid">${cards}</div>
  882. </section>
  883. `;
  884. }
  885. function renderApplyResult(apply) {
  886. const rows = apply.results || [];
  887. return `
  888. <section class="card subpanel">
  889. <div class="card-header">
  890. <h3 class="card-title">一键配置结果</h3>
  891. <div class="card-actions">${badge({ className: apply.ok ? 'ok' : 'warn', label: apply.ok ? '完成' : '部分失败' })}</div>
  892. </div>
  893. <div class="card-body mini-list">
  894. ${rows.map((row) => `
  895. <div class="mini-row">
  896. <div>
  897. <strong>${escapeHtml(row.key)} · ${escapeHtml(row.type)}</strong>
  898. <span class="text-secondary">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
  899. </div>
  900. ${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
  901. </div>
  902. `).join('')}
  903. </div>
  904. </section>
  905. `;
  906. }
  907. function renderDnsGuide(records) {
  908. if (!records.length) {
  909. return `
  910. <div class="empty">
  911. <div class="empty-title">尚未生成检查结果</div>
  912. <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
  913. </div>
  914. `;
  915. }
  916. return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
  917. }
  918. function renderRecordCard(record, index) {
  919. const meta = statusMeta(record);
  920. const current = Array.isArray(record.current) ? record.current : (record.current ? [record.current] : []);
  921. const warnings = record.warnings || [];
  922. return `
  923. <article class="record-card ${meta.className}">
  924. <div class="record-step"><span>${index}</span></div>
  925. <div class="record-card-body">
  926. <div class="record-card-title">
  927. <div>
  928. <h4>${escapeHtml(record.label)}</h4>
  929. <p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p>
  930. ${record.managed ? '<p class="managed-note">平台维护,无需在当前域名 DNS 中配置。</p>' : ''}
  931. </div>
  932. ${badge(meta)}
  933. </div>
  934. <div class="dns-value">
  935. <span>目标值</span>
  936. <code>${escapeHtml(record.value || '')}</code>
  937. ${record.managed ? '' : `<button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>`}
  938. </div>
  939. ${current.length ? `<div class="dns-current"><span>当前值</span>${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}</div>` : ''}
  940. ${warnings.length ? `<ul class="inline-warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>` : ''}
  941. </div>
  942. </article>
  943. `;
  944. }
  945. function renderRecordTable(records) {
  946. if (!records.length) return '<p class="text-secondary">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
  947. return `
  948. <div class="record-table-wrap">
  949. <table class="table table-vcenter">
  950. <thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
  951. <tbody>${records.map(renderRecordRow).join('')}</tbody>
  952. </table>
  953. </div>
  954. `;
  955. }
  956. function renderRecordRow(record) {
  957. const meta = statusMeta(record);
  958. return `
  959. <tr>
  960. <td>${escapeHtml(record.label)}</td>
  961. <td class="mono">${escapeHtml(record.host)}</td>
  962. <td>${escapeHtml(record.type)}</td>
  963. <td><code>${escapeHtml(record.value || '')}</code></td>
  964. <td>${badge(meta)}</td>
  965. <td><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
  966. </tr>
  967. `;
  968. }
  969. function renderSettingsForm(domain) {
  970. const dnsOptions = [
  971. '<option value="">不绑定,手动配置</option>',
  972. ...state.dnsCredentials.map((credential) => `<option value="${credential.id}" ${domain.dnsCredentialId === credential.id ? 'selected' : ''}>${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}</option>`)
  973. ].join('');
  974. return `
  975. <form class="compact-form" data-form="settings">
  976. <div class="mb-3">
  977. <label class="form-label">DNS API</label>
  978. <select class="form-select" name="dnsCredentialId">${dnsOptions}</select>
  979. <div class="form-hint">绑定后才能使用一键配置 DNS。</div>
  980. </div>
  981. <div class="row">
  982. <div class="col-sm-6 mb-3">
  983. <label class="form-label">DKIM selector</label>
  984. <input class="form-control" name="selector" value="${escapeAttr(domain.selector)}">
  985. <div class="form-hint">轮换 DKIM 时也会重新生成 selector。</div>
  986. </div>
  987. <div class="col-sm-6 mb-3">
  988. <label class="form-label">DMARC 策略</label>
  989. <select class="form-select" name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
  990. </div>
  991. </div>
  992. <div class="mb-3"><label class="form-label">发信主机</label><input class="form-control" name="senderHost" value="${escapeAttr(domain.senderHost)}"></div>
  993. <div class="mb-3"><label class="form-label">发信 IP</label><input class="form-control" name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></div>
  994. <div class="mb-3"><label class="form-label">兼容第三方 SPF</label><textarea class="form-control" name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></div>
  995. <div class="mb-3"><label class="form-label">DMARC rua</label><input class="form-control" name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com"></div>
  996. <button class="btn btn-outline-secondary" type="submit">保存设置</button>
  997. </form>
  998. `;
  999. }
  1000. function renderLiveDns(live) {
  1001. if (!live) return '<p class="text-secondary">暂无检查数据</p>';
  1002. const rows = [['根域 TXT', live.rootTxt], ['验证 TXT', live.verificationTxt], ['DKIM TXT', live.dkimTxt], ['DMARC TXT', live.dmarcTxt], ['发信主机 A', live.senderA], ['发信 IP PTR', live.ptr]];
  1003. return `<div class="live-list">${rows.map(([label, values]) => `<div class="live-row"><span>${label}</span>${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="text-secondary">未发现</p>'}</div>`).join('')}</div>`;
  1004. }
  1005. function renderSendForm(domain) {
  1006. return `
  1007. <form class="send-form" data-form="send">
  1008. <div class="mb-3"><label class="form-label">From</label><input class="form-control" name="from" value="noreply@${escapeAttr(domain.domain)}"></div>
  1009. <div class="mb-3"><label class="form-label">To</label><input class="form-control" name="to" placeholder="user@example.com" required></div>
  1010. <div class="mb-3"><label class="form-label">Subject</label><input class="form-control" name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></div>
  1011. <div class="mb-3"><label class="form-label">Text</label><textarea class="form-control" name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea></div>
  1012. <button class="btn btn-primary" type="submit">发送测试</button>
  1013. </form>
  1014. `;
  1015. }
  1016. function renderEvents(domain, limit = 8) {
  1017. const events = state.events.filter((event) => event.domain === domain.domain).slice(0, limit);
  1018. if (!events.length) return '<p class="text-secondary">暂无发送记录</p>';
  1019. return `<div class="event-list">${events.map((event) => `<div class="event-row"><div class="item-line"><strong>${escapeHtml(event.subject)}</strong>${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}</div><span class="text-secondary">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span><span class="text-secondary">${escapeHtml(formatDate(event.createdAt))}</span></div>`).join('')}</div>`;
  1020. }
  1021. function renderSmtpBox(domain) {
  1022. const submission = state.config?.submission;
  1023. const credential = state.smtpCredential;
  1024. const password = credential?.password || '';
  1025. if (!submission?.enabled) return '<p class="text-secondary">SMTP Submission 未启用。</p>';
  1026. return `
  1027. <div class="smtp-grid">
  1028. <div class="smtp-row"><span>Host</span><code>${escapeHtml(submission.host)}</code></div>
  1029. <div class="smtp-row"><span>Ports</span>${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}</div>
  1030. <div class="smtp-row"><span>Username</span><div class="copy-row inline"><code>${escapeHtml(credential?.username || submission.username || '')}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button></div></div>
  1031. <div class="smtp-row"><span>Password</span>${password ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(password)}" type="button">复制</button></div>` : '<p class="text-secondary">旧密码无法回显,请在 SMTP 页面重新设置一次新密码后复制。</p>'}</div>
  1032. <div class="smtp-row"><span>AUTH</span><code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code></div>
  1033. <div class="smtp-row"><span>From</span><code>noreply@${escapeHtml(domain.domain)}</code></div>
  1034. </div>
  1035. `;
  1036. }
  1037. function renderOptionalRecords(records) {
  1038. if (!records.length) return '<p class="text-secondary">完成基础发信配置后可逐步启用。</p>';
  1039. return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
  1040. }
  1041. function renderWarnings(warnings) {
  1042. return `<ul class="warning-list">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`;
  1043. }
  1044. function apiExample(domain) {
  1045. const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
  1046. return `curl -X POST ${state.config?.appBaseUrl || window.location.origin}/api/send \\
  1047. -H 'Authorization: Bearer ${token}' \\
  1048. -H 'Content-Type: application/json' \\
  1049. -d '{
  1050. "from": "noreply@${domain.domain}",
  1051. "to": "user@example.com",
  1052. "subject": "Hello from MailHub",
  1053. "text": "Signed with DKIM and queued by MailHub."
  1054. }'`;
  1055. }
  1056. async function addDomain(event) {
  1057. event.preventDefault();
  1058. await mutate(async () => {
  1059. const data = Object.fromEntries(new FormData(event.target).entries());
  1060. const result = await api('/api/domains', { method: 'POST', body: JSON.stringify(data) });
  1061. state.domains.unshift(result.domain);
  1062. state.selectedId = result.domain.id;
  1063. event.target.reset();
  1064. renderDefaults();
  1065. render();
  1066. await checkSelected();
  1067. });
  1068. }
  1069. async function saveSmtpCredential(event) {
  1070. event.preventDefault();
  1071. await mutate(async () => {
  1072. const data = Object.fromEntries(new FormData(event.target).entries());
  1073. const result = await api('/api/smtp-credential', { method: 'PUT', body: JSON.stringify(data) });
  1074. state.smtpCredential = result.credential;
  1075. if (state.config?.submission) {
  1076. state.config.submission.username = result.credential.username;
  1077. state.config.submission.passwordSet = result.credential.passwordSet;
  1078. }
  1079. els.smtpPassword.value = '';
  1080. renderDefaults();
  1081. render();
  1082. toast('SMTP 凭据已保存');
  1083. });
  1084. }
  1085. async function saveDnsCredential(event) {
  1086. event.preventDefault();
  1087. await mutate(async () => {
  1088. const data = Object.fromEntries(new FormData(event.target).entries());
  1089. const id = Number(data.id || 0);
  1090. delete data.id;
  1091. const path = id ? `/api/dns-credentials/${id}` : '/api/dns-credentials';
  1092. const method = id ? 'PATCH' : 'POST';
  1093. const result = await api(path, { method, body: JSON.stringify(data) });
  1094. if (id) {
  1095. state.dnsCredentials = state.dnsCredentials.map((item) => item.id === id ? result.credential : item);
  1096. } else {
  1097. state.dnsCredentials.unshift(result.credential);
  1098. }
  1099. resetDnsCredentialForm();
  1100. renderDefaults();
  1101. render();
  1102. toast(id ? 'DNS API 已更新' : 'DNS API 已新增');
  1103. });
  1104. }
  1105. function editDnsCredential(id) {
  1106. const credential = state.dnsCredentials.find((item) => item.id === id);
  1107. if (!credential) return;
  1108. setView('dns');
  1109. resetDnsCredentialForm();
  1110. els.dnsCredentialIdField.value = credential.id;
  1111. els.dnsCredentialName.value = credential.name || '';
  1112. els.dnsProvider.value = credential.provider || 'cloudflare';
  1113. els.dnsZoneName.value = credential.zoneName || '';
  1114. els.dnsDefaultTtl.value = credential.defaultTtl || 600;
  1115. els.dnsCredentialFormTitle.textContent = `编辑 ${credential.name}`;
  1116. els.dnsCredentialFormHint.textContent = '密钥字段留空会保留原值。';
  1117. toggleDnsProviderFields();
  1118. els.dnsCredentialName.focus();
  1119. }
  1120. function resetDnsCredentialForm() {
  1121. els.dnsCredentialForm.reset();
  1122. els.dnsCredentialIdField.value = '';
  1123. els.dnsDefaultTtl.value = 600;
  1124. els.dnsCredentialFormTitle.textContent = '新增 DNS API';
  1125. els.dnsCredentialFormHint.textContent = '选择服务商后只填写对应字段。';
  1126. toggleDnsProviderFields();
  1127. }
  1128. function toggleDnsProviderFields() {
  1129. const provider = els.dnsProvider.value || 'cloudflare';
  1130. for (const fieldset of document.querySelectorAll('[data-provider-field]')) {
  1131. fieldset.classList.toggle('hidden', fieldset.dataset.providerField !== provider);
  1132. }
  1133. }
  1134. async function createApiToken(event) {
  1135. event.preventDefault();
  1136. await mutate(async () => {
  1137. const data = Object.fromEntries(new FormData(event.target).entries());
  1138. const result = await api('/api/api-tokens', { method: 'POST', body: JSON.stringify(data) });
  1139. state.apiTokens.unshift(result.token);
  1140. event.target.reset();
  1141. renderDefaults();
  1142. render();
  1143. await navigator.clipboard.writeText(result.token.token);
  1144. toast('Token 已生成并复制,请立即保存');
  1145. });
  1146. }
  1147. function generateSmtpPassword() {
  1148. const bytes = new Uint8Array(24);
  1149. crypto.getRandomValues(bytes);
  1150. const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
  1151. els.smtpPassword.type = 'text';
  1152. els.smtpPassword.value = password;
  1153. els.smtpPassword.focus();
  1154. els.smtpPassword.select();
  1155. }
  1156. async function handleGlobalClick(event) {
  1157. const viewButton = event.target.closest('[data-view]');
  1158. if (viewButton && !event.target.closest('[data-domain-id]')) {
  1159. event.preventDefault();
  1160. setView(viewButton.dataset.view);
  1161. render();
  1162. return;
  1163. }
  1164. const copyButton = event.target.closest('[data-copy]');
  1165. if (copyButton) {
  1166. event.preventDefault();
  1167. const value = copyButton.dataset.copy || '';
  1168. if (!value) return;
  1169. await navigator.clipboard.writeText(value);
  1170. toast('已复制');
  1171. return;
  1172. }
  1173. const actionTarget = event.target.closest('[data-action]');
  1174. const action = actionTarget?.dataset.action;
  1175. if (!action) return;
  1176. const id = Number(actionTarget.closest('[data-id]')?.dataset.id || actionTarget.dataset.id || 0);
  1177. if (action === 'check') return checkSelected();
  1178. if (action === 'apply-dns') return applyDnsSelected();
  1179. if (action === 'copy-all-dns') return copyAllDns();
  1180. if (action === 'detail-tab') return selectDetailTab(actionTarget.dataset.tab);
  1181. if (action === 'rotate-dkim') return rotateDkim();
  1182. if (action === 'delete-domain') return deleteSelected();
  1183. if (action === 'edit-dns') return editDnsCredential(id);
  1184. if (action === 'test-dns') return testDnsCredential(id);
  1185. if (action === 'delete-dns') return deleteDnsCredential(id);
  1186. if (action === 'delete-token') return deleteApiToken(id);
  1187. if (action === 'toggle-user') return toggleUser(id, actionTarget.closest('[data-status]')?.dataset.status);
  1188. }
  1189. async function handleDetailSubmit(event) {
  1190. const form = event.target.closest('form[data-form]');
  1191. if (!form) return;
  1192. event.preventDefault();
  1193. if (form.dataset.form === 'settings') return saveSettings(form);
  1194. if (form.dataset.form === 'send') return sendTest(form);
  1195. if (form.dataset.form === 'admin-settings') return saveAdminSettings(form);
  1196. }
  1197. function selectDetailTab(tab) {
  1198. const allowed = ['dns', 'settings', 'send', 'integrations', 'events'];
  1199. if (!allowed.includes(tab)) return;
  1200. state.detailTab = tab;
  1201. renderDetail();
  1202. }
  1203. async function checkSelected() {
  1204. const id = state.selectedId;
  1205. if (!id) return;
  1206. await mutate(async () => {
  1207. const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
  1208. replaceDomain(result.domain);
  1209. render();
  1210. });
  1211. }
  1212. async function applyDnsSelected() {
  1213. const id = state.selectedId;
  1214. if (!id) return;
  1215. await mutate(async () => {
  1216. const result = await api(`/api/domains/${id}/apply-dns`, { method: 'POST' });
  1217. replaceDomain(result.domain);
  1218. render();
  1219. toast(result.apply?.ok ? 'DNS 配置完成' : 'DNS 配置部分失败');
  1220. });
  1221. }
  1222. async function saveSettings(form) {
  1223. const id = state.selectedId;
  1224. const data = Object.fromEntries(new FormData(form).entries());
  1225. await mutate(async () => {
  1226. const result = await api(`/api/domains/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
  1227. replaceDomain(result.domain);
  1228. render();
  1229. await checkSelected();
  1230. });
  1231. }
  1232. async function sendTest(form) {
  1233. const id = state.selectedId;
  1234. const data = Object.fromEntries(new FormData(form).entries());
  1235. await mutate(async () => {
  1236. await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
  1237. const [events, analytics] = await Promise.all([
  1238. api('/api/events'),
  1239. api('/api/analytics?days=7')
  1240. ]);
  1241. state.events = events.events || [];
  1242. state.analytics = analytics.analytics || state.analytics;
  1243. renderDefaults();
  1244. render();
  1245. toast('已提交到发信队列');
  1246. });
  1247. }
  1248. async function saveAdminSettings(form) {
  1249. const data = Object.fromEntries(new FormData(form).entries());
  1250. await mutate(async () => {
  1251. const result = await api('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(data) });
  1252. state.settings = result.settings;
  1253. state.config = { ...state.config, ...result.settings };
  1254. renderDefaults();
  1255. render();
  1256. toast('系统设置已保存');
  1257. });
  1258. }
  1259. async function rotateDkim() {
  1260. const id = state.selectedId;
  1261. if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
  1262. await mutate(async () => {
  1263. const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' });
  1264. replaceDomain(result.domain);
  1265. render();
  1266. await checkSelected();
  1267. });
  1268. }
  1269. async function deleteSelected() {
  1270. const id = state.selectedId;
  1271. const domain = state.domains.find((item) => item.id === id);
  1272. if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return;
  1273. await mutate(async () => {
  1274. await api(`/api/domains/${id}`, { method: 'DELETE' });
  1275. state.domains = state.domains.filter((item) => item.id !== id);
  1276. state.selectedId = state.domains[0]?.id || null;
  1277. renderDefaults();
  1278. render();
  1279. });
  1280. }
  1281. async function testDnsCredential(id) {
  1282. await mutate(async () => {
  1283. const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
  1284. state.dnsTestResults[id] = {
  1285. ok: result.ok,
  1286. message: result.ok ? `连接成功:${result.detail || providerLabel(result.provider)}` : (result.error || 'DNS API 连接失败')
  1287. };
  1288. renderDnsCredentials();
  1289. toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败', result.ok ? 'info' : 'error');
  1290. });
  1291. }
  1292. async function deleteDnsCredential(id) {
  1293. const credential = state.dnsCredentials.find((item) => item.id === id);
  1294. if (!confirm(`删除 ${credential?.name || '该 DNS API 凭据'}?`)) return;
  1295. await mutate(async () => {
  1296. await api(`/api/dns-credentials/${id}`, { method: 'DELETE' });
  1297. state.dnsCredentials = state.dnsCredentials.filter((item) => item.id !== id);
  1298. state.domains = state.domains.map((domain) => domain.dnsCredentialId === id ? { ...domain, dnsCredentialId: null } : domain);
  1299. if (Number(els.dnsCredentialIdField.value || 0) === id) resetDnsCredentialForm();
  1300. renderDefaults();
  1301. render();
  1302. });
  1303. }
  1304. async function deleteApiToken(id) {
  1305. if (!confirm('删除该 API Token?')) return;
  1306. await mutate(async () => {
  1307. await api(`/api/api-tokens/${id}`, { method: 'DELETE' });
  1308. state.apiTokens = state.apiTokens.filter((item) => item.id !== id);
  1309. renderDefaults();
  1310. render();
  1311. });
  1312. }
  1313. async function toggleUser(id, status) {
  1314. const next = status === 'active' ? 'disabled' : 'active';
  1315. await mutate(async () => {
  1316. const result = await api(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify({ status: next }) });
  1317. state.users = state.users.map((user) => user.id === id ? result.user : user);
  1318. renderDefaults();
  1319. });
  1320. }
  1321. async function copyAllDns() {
  1322. const domain = state.domains.find((item) => item.id === state.selectedId);
  1323. const records = domain?.status?.records || [];
  1324. if (!records.length) return toast('暂无 DNS 记录');
  1325. const text = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
  1326. await navigator.clipboard.writeText(text);
  1327. toast('已复制全部 DNS 记录');
  1328. }
  1329. async function logout() {
  1330. setBusy(true);
  1331. try {
  1332. await fetch('/api/logout', { method: 'POST' });
  1333. } finally {
  1334. window.location.href = '/login';
  1335. }
  1336. }
  1337. async function mutate(fn) {
  1338. setBusy(true);
  1339. try {
  1340. await fn();
  1341. } catch (error) {
  1342. toast(error.message, 'error');
  1343. } finally {
  1344. setBusy(false);
  1345. }
  1346. }
  1347. async function api(path, options = {}) {
  1348. const response = await fetch(path, {
  1349. ...options,
  1350. headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
  1351. });
  1352. const text = await response.text();
  1353. const payload = text ? JSON.parse(text) : {};
  1354. if (!response.ok) {
  1355. if (response.status === 401) {
  1356. window.location.href = '/login';
  1357. return {};
  1358. }
  1359. throw new Error(payload.error || `HTTP ${response.status}`);
  1360. }
  1361. return payload;
  1362. }
  1363. function replaceDomain(domain) {
  1364. state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
  1365. }
  1366. function statusLabel(status) {
  1367. return {
  1368. queued: '成功入队',
  1369. failed: '发送失败'
  1370. }[status] || status || 'unknown';
  1371. }
  1372. function statusMeta(target) {
  1373. if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
  1374. if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
  1375. if (target.status === 'pending') return { className: 'pending', label: '传播中' };
  1376. if (target.status === 'missing') return { className: 'missing', label: '缺失' };
  1377. if (target.status === 'warn') return { className: 'warn', label: '需调整' };
  1378. return { className: 'warn', label: '待配置' };
  1379. }
  1380. function badge(meta) {
  1381. return `<span class="badge ${badgeTone(meta.className)}">${escapeHtml(meta.label)}</span>`;
  1382. }
  1383. function badgeTone(className) {
  1384. return {
  1385. ok: 'bg-green-lt',
  1386. pending: 'bg-blue-lt',
  1387. warn: 'bg-yellow-lt',
  1388. missing: 'bg-red-lt',
  1389. failed: 'bg-red-lt',
  1390. idle: 'bg-secondary-lt'
  1391. }[className] || 'bg-secondary-lt';
  1392. }
  1393. function providerLabel(provider) {
  1394. return { cloudflare: 'Cloudflare', aliyun: '阿里云 DNS', dnspod: '腾讯云 DNSPod' }[provider] || provider;
  1395. }
  1396. function setBusy(value) {
  1397. state.busy = value;
  1398. document.body.classList.toggle('busy', value);
  1399. els.refreshButton.disabled = value;
  1400. if (els.refreshButtonMobile) els.refreshButtonMobile.disabled = value;
  1401. }
  1402. function toast(message, tone = 'info') {
  1403. const node = document.createElement('div');
  1404. node.className = `app-toast ${tone}`;
  1405. node.textContent = message;
  1406. els.toastHost.appendChild(node);
  1407. setTimeout(() => node.remove(), 3200);
  1408. }
  1409. function formatDate(value) {
  1410. if (!value) return '';
  1411. return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
  1412. }
  1413. function escapeHtml(value) {
  1414. return String(value ?? '')
  1415. .replace(/&/g, '&amp;')
  1416. .replace(/</g, '&lt;')
  1417. .replace(/>/g, '&gt;')
  1418. .replace(/"/g, '&quot;')
  1419. .replace(/'/g, '&#039;');
  1420. }
  1421. function escapeAttr(value) {
  1422. return escapeHtml(value).replace(/`/g, '&#096;');
  1423. }