app.js 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  1. const state = {
  2. me: null,
  3. config: null,
  4. smtpCredential: null,
  5. dnsCredentials: [],
  6. apiTokens: [],
  7. domains: [],
  8. events: [],
  9. users: [],
  10. settings: null,
  11. selectedId: null,
  12. view: 'domains',
  13. busy: false
  14. };
  15. const viewMeta = {
  16. domains: {
  17. eyebrow: 'Domains',
  18. title: '发信域名',
  19. subtitle: '管理发信域名、DNS 检查和测试发送。'
  20. },
  21. dns: {
  22. eyebrow: 'DNS API',
  23. title: 'DNS API 凭据',
  24. subtitle: '按服务商保存多组 DNS 凭据,再绑定到具体域名。'
  25. },
  26. smtp: {
  27. eyebrow: 'SMTP',
  28. title: 'SMTP 凭据',
  29. subtitle: '生成可复制的 SMTP 用户名和密码。'
  30. },
  31. tokens: {
  32. eyebrow: 'API Tokens',
  33. title: '发送 API Token',
  34. subtitle: '为不同系统创建独立发信 Token。'
  35. },
  36. admin: {
  37. eyebrow: 'Admin',
  38. title: '系统设置',
  39. subtitle: '管理员设置默认发信参数和用户状态。'
  40. }
  41. };
  42. const els = {
  43. runtimeLine: document.querySelector('#runtimeLine'),
  44. viewEyebrow: document.querySelector('#viewEyebrow'),
  45. viewTitle: document.querySelector('#viewTitle'),
  46. securityNotice: document.querySelector('#securityNotice'),
  47. accountBox: document.querySelector('#accountBox'),
  48. userRole: document.querySelector('#userRole'),
  49. navButtons: document.querySelectorAll('.primary-nav [data-view]'),
  50. viewPanels: document.querySelectorAll('[data-view-panel]'),
  51. adminNavButton: document.querySelector('#adminNavButton'),
  52. addDomainForm: document.querySelector('#addDomainForm'),
  53. domainDnsCredential: document.querySelector('#domainDnsCredential'),
  54. domainReadiness: document.querySelector('#domainReadiness'),
  55. smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
  56. smtpCredentialState: document.querySelector('#smtpCredentialState'),
  57. smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
  58. smtpUsername: document.querySelector('#smtpUsername'),
  59. smtpPassword: document.querySelector('#smtpPassword'),
  60. generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
  61. dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
  62. dnsCredentialFormTitle: document.querySelector('#dnsCredentialFormTitle'),
  63. dnsCredentialFormHint: document.querySelector('#dnsCredentialFormHint'),
  64. dnsCredentialIdField: document.querySelector('#dnsCredentialIdField'),
  65. dnsCredentialName: document.querySelector('#dnsCredentialName'),
  66. dnsDefaultTtl: document.querySelector('#dnsDefaultTtl'),
  67. dnsProvider: document.querySelector('#dnsProvider'),
  68. dnsZoneName: document.querySelector('#dnsZoneName'),
  69. dnsCredentialList: document.querySelector('#dnsCredentialList'),
  70. dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
  71. resetDnsCredentialForm: document.querySelector('#resetDnsCredentialForm'),
  72. apiTokenForm: document.querySelector('#apiTokenForm'),
  73. apiTokenList: document.querySelector('#apiTokenList'),
  74. apiTokenCount: document.querySelector('#apiTokenCount'),
  75. defaultSenderHost: document.querySelector('#defaultSenderHost'),
  76. defaultSendingIp: document.querySelector('#defaultSendingIp'),
  77. defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
  78. domainList: document.querySelector('#domainList'),
  79. domainCount: document.querySelector('#domainCount'),
  80. detailPanel: document.querySelector('#detailPanel'),
  81. adminPanel: document.querySelector('#adminPanel'),
  82. refreshButton: document.querySelector('#refreshButton'),
  83. refreshButtonMobile: document.querySelector('#refreshButtonMobile'),
  84. logoutButton: document.querySelector('#logoutButton'),
  85. toastHost: document.querySelector('#toastHost')
  86. };
  87. init();
  88. async function init() {
  89. bindEvents();
  90. await refreshAll();
  91. }
  92. function bindEvents() {
  93. els.refreshButton.addEventListener('click', refreshAll);
  94. els.refreshButtonMobile?.addEventListener('click', refreshAll);
  95. els.logoutButton.addEventListener('click', logout);
  96. document.addEventListener('click', handleGlobalClick);
  97. els.addDomainForm.addEventListener('submit', addDomain);
  98. els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
  99. els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
  100. els.dnsCredentialForm.addEventListener('submit', saveDnsCredential);
  101. els.dnsProvider.addEventListener('change', toggleDnsProviderFields);
  102. els.resetDnsCredentialForm.addEventListener('click', resetDnsCredentialForm);
  103. els.apiTokenForm.addEventListener('submit', createApiToken);
  104. els.domainList.addEventListener('click', (event) => {
  105. const item = event.target.closest('[data-domain-id]');
  106. if (!item) return;
  107. state.selectedId = Number(item.dataset.domainId);
  108. setView('domains');
  109. render();
  110. });
  111. els.detailPanel.addEventListener('submit', handleDetailSubmit);
  112. els.adminPanel.addEventListener('submit', handleDetailSubmit);
  113. }
  114. async function refreshAll() {
  115. setBusy(true);
  116. try {
  117. const me = await api('/api/me');
  118. state.me = me.user;
  119. const baseCalls = [
  120. api('/api/config'),
  121. api('/api/domains'),
  122. api('/api/events'),
  123. api('/api/smtp-credential'),
  124. api('/api/dns-credentials'),
  125. api('/api/api-tokens')
  126. ];
  127. const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
  128. state.config = config;
  129. state.domains = domains.domains || [];
  130. state.events = events.events || [];
  131. state.smtpCredential = smtpCredential.credential || null;
  132. state.dnsCredentials = dnsCredentials.credentials || [];
  133. state.apiTokens = apiTokens.tokens || [];
  134. if (state.me?.role === 'admin') {
  135. const [settings, users] = await Promise.all([
  136. api('/api/admin/settings'),
  137. api('/api/admin/users')
  138. ]);
  139. state.settings = settings.settings || null;
  140. state.users = users.users || [];
  141. }
  142. if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
  143. if (state.selectedId && !state.domains.some((domain) => domain.id === state.selectedId)) {
  144. state.selectedId = state.domains[0]?.id || null;
  145. }
  146. renderDefaults();
  147. render();
  148. } catch (error) {
  149. toast(error.message, 'error');
  150. } finally {
  151. setBusy(false);
  152. }
  153. }
  154. function renderDefaults() {
  155. if (!state.config) return;
  156. const ip = state.config.sendingIp || '未设置发信 IP';
  157. els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${viewMeta[state.view]?.subtitle || ''}`;
  158. els.defaultSenderHost.value = state.config.mailHostname || '';
  159. els.defaultSendingIp.value = state.config.sendingIp || '';
  160. els.defaultSpfExtra.value = state.config.defaultSpfMechanisms || '';
  161. els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || '';
  162. els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置';
  163. els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户';
  164. els.userRole.className = `badge ${state.me?.role === 'admin' ? 'bg-green-lt' : 'bg-secondary-lt'}`;
  165. els.adminNavButton.classList.toggle('hidden', state.me?.role !== 'admin');
  166. if (state.me?.role !== 'admin' && state.view === 'admin') setView('domains');
  167. els.accountBox.innerHTML = `
  168. <div class="list-group-item px-0 py-2 bg-transparent border-0">
  169. <div class="text-secondary small">Username</div>
  170. <code>${escapeHtml(state.me?.username || '')}</code>
  171. </div>
  172. <div class="list-group-item px-0 py-2 bg-transparent border-0">
  173. <div class="text-secondary small">Email</div>
  174. <code>${escapeHtml(state.me?.email || '')}</code>
  175. </div>
  176. `;
  177. renderDnsOptions();
  178. renderDomainReadiness();
  179. renderSmtpCredentialCopy();
  180. renderDnsCredentials();
  181. renderApiTokens();
  182. renderAdminPanel();
  183. renderSecurityNotice();
  184. toggleDnsProviderFields();
  185. }
  186. function render() {
  187. els.domainCount.textContent = String(state.domains.length);
  188. els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
  189. els.apiTokenCount.textContent = String(state.apiTokens.length);
  190. renderViewMeta();
  191. renderDomainList();
  192. renderDetail();
  193. }
  194. function renderViewMeta() {
  195. const meta = viewMeta[state.view] || viewMeta.domains;
  196. els.viewEyebrow.textContent = meta.eyebrow;
  197. els.viewTitle.textContent = meta.title;
  198. if (state.config) {
  199. const ip = state.config.sendingIp || '未设置发信 IP';
  200. els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${meta.subtitle}`;
  201. }
  202. for (const button of els.navButtons) {
  203. button.classList.toggle('active', button.dataset.view === state.view);
  204. }
  205. for (const panel of els.viewPanels) {
  206. panel.classList.toggle('active', panel.dataset.viewPanel === state.view);
  207. }
  208. }
  209. function setView(view) {
  210. if (!viewMeta[view]) return;
  211. if (view === 'admin' && state.me?.role !== 'admin') return;
  212. state.view = view;
  213. renderViewMeta();
  214. }
  215. function renderSecurityNotice() {
  216. els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
  217. els.securityNotice.textContent = state.config.usingDefaultAdminPassword
  218. ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
  219. : '';
  220. }
  221. function renderDomainReadiness() {
  222. const verified = state.domains.filter((domain) => domain.status?.verified).length;
  223. const latestEvent = state.events[0];
  224. els.domainReadiness.innerHTML = `
  225. <div class="card-header">
  226. <div>
  227. <h3 class="card-title">运行概览</h3>
  228. <p class="card-subtitle">关键配置集中在这里,缺哪一项就先补哪一项。</p>
  229. </div>
  230. </div>
  231. <div class="card-body">
  232. <div class="row row-cards">
  233. ${metricCard('域名', state.domains.length, `${verified} 个已通过`)}
  234. ${metricCard('DNS API', state.dnsCredentials.length, state.dnsCredentials.length ? '可一键写入' : '尚未配置')}
  235. ${metricCard('SMTP', state.smtpCredential?.passwordSet ? 'Ready' : 'Missing', state.smtpCredential?.username || '未设置凭据')}
  236. ${metricCard('最近发送', latestEvent ? formatDate(latestEvent.createdAt) : '暂无', latestEvent?.status || '无记录')}
  237. </div>
  238. <div class="d-flex flex-wrap gap-2 mt-3">
  239. ${!state.dnsCredentials.length ? '<button class="btn btn-outline-secondary" data-view="dns" type="button">新增 DNS API</button>' : ''}
  240. ${!state.smtpCredential?.passwordSet ? '<button class="btn btn-outline-secondary" data-view="smtp" type="button">设置 SMTP</button>' : ''}
  241. <button class="btn btn-outline-secondary" data-action="check" type="button" ${state.selectedId ? '' : 'disabled'}>检查当前域名</button>
  242. </div>
  243. </div>
  244. `;
  245. }
  246. function metricCard(label, value, hint) {
  247. return `
  248. <div class="col-sm-6">
  249. <div class="card card-sm metric-card">
  250. <div class="card-body">
  251. <div class="text-secondary small">${escapeHtml(label)}</div>
  252. <div class="h2 m-0">${escapeHtml(value)}</div>
  253. <div class="text-secondary small">${escapeHtml(hint || '')}</div>
  254. </div>
  255. </div>
  256. </div>
  257. `;
  258. }
  259. function renderDnsOptions(selectedId = '') {
  260. const activeId = selectedId || els.domainDnsCredential.value || '';
  261. if (!state.dnsCredentials.length) {
  262. els.domainDnsCredential.innerHTML = '<option value="">未绑定,先手动配置或新增 DNS API</option>';
  263. return;
  264. }
  265. const options = [
  266. '<option value="">不绑定,手动配置</option>',
  267. ...state.dnsCredentials.map((credential) => `
  268. <option value="${credential.id}" ${String(activeId) === String(credential.id) ? 'selected' : ''}>
  269. ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}
  270. </option>
  271. `)
  272. ].join('');
  273. els.domainDnsCredential.innerHTML = options;
  274. }
  275. function renderSmtpCredentialCopy() {
  276. const credential = state.smtpCredential;
  277. if (!credential?.username && !credential?.passwordSet) {
  278. els.smtpCredentialCopy.innerHTML = `
  279. <div class="empty">
  280. <div class="empty-title">还没有 SMTP 凭据</div>
  281. <p>保存用户名和密码后,这里会显示可复制内容。</p>
  282. </div>
  283. `;
  284. return;
  285. }
  286. const password = credential?.password || '';
  287. els.smtpCredentialCopy.innerHTML = `
  288. <div class="copy-row">
  289. <span>Username</span>
  290. <code>${escapeHtml(credential.username || '')}</code>
  291. <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
  292. </div>
  293. <div class="copy-row">
  294. <span>Password</span>
  295. ${password
  296. ? `<code>${escapeHtml(password)}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(password)}" type="button">复制</button>`
  297. : '<p class="text-secondary">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
  298. </div>
  299. `;
  300. }
  301. function renderDnsCredentials() {
  302. els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
  303. if (!state.dnsCredentials.length) {
  304. els.dnsCredentialList.innerHTML = `
  305. <div class="empty">
  306. <div class="empty-title">暂无 DNS API 凭据</div>
  307. <p>先在右侧新增一组凭据;添加域名时下拉框就会出现可选项。</p>
  308. </div>
  309. `;
  310. return;
  311. }
  312. els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
  313. <article class="card credential-row">
  314. <div class="card-body">
  315. <div>
  316. <div class="d-flex align-items-center justify-content-between gap-2 mb-1">
  317. <strong>${escapeHtml(credential.name)}</strong>
  318. ${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
  319. </div>
  320. <span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
  321. </div>
  322. <div class="d-flex flex-wrap gap-2 justify-content-end">
  323. <button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
  324. <button class="btn btn-sm btn-outline-secondary" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
  325. <button class="btn btn-sm btn-outline-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
  326. </div>
  327. </div>
  328. </article>
  329. `).join('');
  330. }
  331. function renderApiTokens() {
  332. els.apiTokenCount.textContent = String(state.apiTokens.length);
  333. if (!state.apiTokens.length) {
  334. els.apiTokenList.innerHTML = `
  335. <div class="empty">
  336. <div class="empty-title">暂无发送 Token</div>
  337. <p>创建后请立即保存完整 Token,页面之后只显示前缀。</p>
  338. </div>
  339. `;
  340. return;
  341. }
  342. els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
  343. <div class="mini-row">
  344. <div>
  345. <strong>${escapeHtml(token.name)}</strong>
  346. <span class="text-secondary">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
  347. </div>
  348. <button class="btn btn-sm btn-outline-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
  349. </div>
  350. `).join('');
  351. }
  352. function renderAdminPanel() {
  353. if (state.me?.role !== 'admin') {
  354. els.adminPanel.classList.add('hidden');
  355. els.adminPanel.innerHTML = '';
  356. return;
  357. }
  358. const settings = state.settings || state.config || {};
  359. els.adminPanel.classList.remove('hidden');
  360. els.adminPanel.innerHTML = `
  361. <div class="card-header">
  362. <div>
  363. <h3 class="card-title">系统默认值</h3>
  364. <p class="card-subtitle">这些值会作为新域名的默认 DNS 和发信参数。</p>
  365. </div>
  366. <div class="card-actions">
  367. <span class="badge bg-blue-lt">${state.users.length} 用户</span>
  368. </div>
  369. </div>
  370. <div class="card-body">
  371. <form class="compact-form" data-form="admin-settings">
  372. <div class="mb-3">
  373. <label class="form-label">APP Base URL</label>
  374. <input class="form-control" name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}">
  375. <div class="form-hint">API 示例和外部访问地址。</div>
  376. </div>
  377. <div class="row">
  378. <div class="col-sm-6 mb-3">
  379. <label class="form-label">发信主机</label>
  380. <input class="form-control" name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}">
  381. <div class="form-hint">建议使用 mail.example.com。</div>
  382. </div>
  383. <div class="col-sm-6 mb-3">
  384. <label class="form-label">发信 IP</label>
  385. <input class="form-control" name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}">
  386. <div class="form-hint">服务器公网 IPv4。</div>
  387. </div>
  388. </div>
  389. <div class="mb-3">
  390. <label class="form-label">默认 SPF</label>
  391. <textarea class="form-control" name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea>
  392. <div class="form-hint">会合并到 v=spf1 中,不要重复写 v=spf1。</div>
  393. </div>
  394. <div class="row">
  395. <div class="col-sm-6 mb-3">
  396. <label class="form-label">DMARC</label>
  397. <select class="form-select" name="dmarcPolicy">
  398. ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
  399. </select>
  400. </div>
  401. <div class="col-sm-6 mb-3">
  402. <label class="form-label">验证后发信</label>
  403. <select class="form-select" name="sendRequiresVerified">
  404. <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
  405. <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
  406. </select>
  407. </div>
  408. </div>
  409. <div class="mb-3">
  410. <label class="form-label">DMARC rua</label>
  411. <input class="form-control" name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
  412. </div>
  413. <button class="btn btn-primary" type="submit">保存系统设置</button>
  414. </form>
  415. </div>
  416. <div class="card-header border-top">
  417. <div>
  418. <h3 class="card-title">用户状态</h3>
  419. <p class="card-subtitle">禁用用户后,该用户的控制台、API Token 和 SMTP 认证都会失效。</p>
  420. </div>
  421. </div>
  422. <div class="card-body">
  423. <div class="mini-list user-mini-list">
  424. ${state.users.slice(0, 12).map((user) => `
  425. <div class="mini-row">
  426. <div>
  427. <strong>${escapeHtml(user.username)}</strong>
  428. <span class="text-secondary">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
  429. </div>
  430. <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">
  431. ${user.status === 'active' ? '禁用' : '启用'}
  432. </button>
  433. </div>
  434. `).join('')}
  435. </div>
  436. </div>
  437. `;
  438. }
  439. function renderDomainList() {
  440. if (!state.domains.length) {
  441. els.domainList.innerHTML = '<div class="empty-mini">暂无域名</div>';
  442. return;
  443. }
  444. els.domainList.innerHTML = state.domains.map((domain) => {
  445. const status = statusMeta(domain.status);
  446. return `
  447. <button class="domain-item ${domain.id === state.selectedId ? 'active' : ''}" data-domain-id="${domain.id}" type="button">
  448. <span>${escapeHtml(domain.domain)}</span>
  449. ${badge(status)}
  450. <small>${escapeHtml(domain.selector)}._domainkey</small>
  451. </button>
  452. `;
  453. }).join('');
  454. }
  455. function renderDetail() {
  456. const domain = state.domains.find((item) => item.id === state.selectedId);
  457. if (!domain) {
  458. els.detailPanel.innerHTML = `
  459. <div class="empty-state">
  460. <h2>选择或添加一个域名</h2>
  461. <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
  462. </div>
  463. `;
  464. return;
  465. }
  466. const guide = domain.status || {};
  467. const records = guide.records || [];
  468. const warnings = guide.warnings || [];
  469. const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
  470. const applyDisabled = domain.dnsCredentialId ? '' : 'disabled';
  471. const applyTitle = domain.dnsCredentialId ? '一键配置 DNS' : '请先在域名设置里绑定 DNS API 凭据';
  472. els.detailPanel.innerHTML = `
  473. <div class="card-header detail-header">
  474. <div>
  475. <h3 class="card-title">${escapeHtml(domain.domain)}</h3>
  476. <div class="status-strip">
  477. ${badge(statusMeta(guide))}
  478. <span class="badge bg-secondary-lt">最近检查 ${escapeHtml(checkedAt)}</span>
  479. <span class="badge bg-secondary-lt">Selector ${escapeHtml(domain.selector)}</span>
  480. </div>
  481. </div>
  482. <div class="card-actions detail-actions">
  483. <button class="btn btn-primary" data-action="apply-dns" title="${escapeAttr(applyTitle)}" type="button" ${applyDisabled}>一键配置 DNS</button>
  484. <button class="btn btn-outline-secondary" data-action="check" type="button">立即检查</button>
  485. <button class="btn btn-outline-secondary" data-action="rotate-dkim" type="button">轮换 DKIM</button>
  486. <button class="btn btn-outline-danger" data-action="delete-domain" type="button">删除</button>
  487. </div>
  488. </div>
  489. <div class="card-body detail-body">
  490. ${renderSetupOverview(domain, guide, records)}
  491. ${guide.apply ? renderApplyResult(guide.apply) : ''}
  492. ${warnings.length ? renderWarnings(warnings) : ''}
  493. <div class="row row-cards">
  494. <section class="col-lg-7">
  495. <div class="card subpanel">
  496. <div class="card-header">
  497. <h3 class="card-title">配置引导</h3>
  498. <div class="card-actions"><button class="btn btn-sm btn-outline-secondary" data-action="copy-all-dns" type="button">复制全部</button></div>
  499. </div>
  500. <div class="card-body">
  501. ${renderDnsGuide(records)}
  502. </div>
  503. </div>
  504. </section>
  505. <section class="col-lg-5">
  506. <div class="card subpanel">
  507. <div class="card-header"><h3 class="card-title">域名设置</h3></div>
  508. <div class="card-body">${renderSettingsForm(domain)}</div>
  509. </div>
  510. </section>
  511. </div>
  512. <div class="row row-cards">
  513. <section class="col-lg-6">
  514. <div class="card subpanel">
  515. <div class="card-header"><h3 class="card-title">当前 DNS</h3></div>
  516. <div class="card-body">${renderLiveDns(guide.live)}</div>
  517. </div>
  518. </section>
  519. <section class="col-lg-6">
  520. <div class="card subpanel">
  521. <div class="card-header"><h3 class="card-title">测试发送</h3></div>
  522. <div class="card-body">${renderSendForm(domain)}</div>
  523. </div>
  524. </section>
  525. </div>
  526. <div class="row row-cards">
  527. <section class="col-lg-6">
  528. <div class="card subpanel api-box">
  529. <div class="card-header"><h3 class="card-title">发送 API</h3></div>
  530. <div class="card-body"><pre><code>${escapeHtml(apiExample(domain))}</code></pre></div>
  531. </div>
  532. </section>
  533. <section class="col-lg-6">
  534. <div class="card subpanel">
  535. <div class="card-header"><h3 class="card-title">SMTP 发信</h3></div>
  536. <div class="card-body">${renderSmtpBox(domain)}</div>
  537. </div>
  538. </section>
  539. </div>
  540. <section class="card subpanel">
  541. <div class="card-header"><h3 class="card-title">最近发送</h3></div>
  542. <div class="card-body">${renderEvents(domain)}</div>
  543. </section>
  544. <section class="card subpanel">
  545. <div class="card-header"><h3 class="card-title">增强安全记录</h3></div>
  546. <div class="card-body">${renderOptionalRecords(guide.optionalRecords || [])}</div>
  547. </section>
  548. </div>
  549. `;
  550. }
  551. function renderSetupOverview(domain, guide, records) {
  552. const important = [['verification', '域名验证'], ['dkim', 'DKIM'], ['spf', 'SPF'], ['dmarc', 'DMARC'], ['ptr', 'PTR']];
  553. const cards = important.map(([key, label]) => {
  554. const record = records.find((item) => item.key === key);
  555. const meta = statusMeta(record || {});
  556. return `<div class="metric-card compact"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
  557. }).join('');
  558. const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
  559. return `
  560. <section class="guide-hero">
  561. <div>
  562. <span class="eyebrow">Sending domain</span>
  563. <h3>${escapeHtml(domain.domain)}</h3>
  564. <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp || '未设置 IP')}</p>
  565. <p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
  566. </div>
  567. <div class="summary-grid">${cards}</div>
  568. </section>
  569. `;
  570. }
  571. function renderApplyResult(apply) {
  572. const rows = apply.results || [];
  573. return `
  574. <section class="card subpanel">
  575. <div class="card-header">
  576. <h3 class="card-title">一键配置结果</h3>
  577. <div class="card-actions">${badge({ className: apply.ok ? 'ok' : 'warn', label: apply.ok ? '完成' : '部分失败' })}</div>
  578. </div>
  579. <div class="card-body mini-list">
  580. ${rows.map((row) => `
  581. <div class="mini-row">
  582. <div>
  583. <strong>${escapeHtml(row.key)} · ${escapeHtml(row.type)}</strong>
  584. <span class="text-secondary">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
  585. </div>
  586. ${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
  587. </div>
  588. `).join('')}
  589. </div>
  590. </section>
  591. `;
  592. }
  593. function renderDnsGuide(records) {
  594. if (!records.length) {
  595. return `
  596. <div class="empty">
  597. <div class="empty-title">尚未生成检查结果</div>
  598. <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
  599. </div>
  600. `;
  601. }
  602. return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
  603. }
  604. function renderRecordCard(record, index) {
  605. const meta = statusMeta(record);
  606. const current = Array.isArray(record.current) ? record.current : (record.current ? [record.current] : []);
  607. const warnings = record.warnings || [];
  608. return `
  609. <article class="record-card ${meta.className}">
  610. <div class="record-step"><span>${index}</span></div>
  611. <div class="record-card-body">
  612. <div class="record-card-title">
  613. <div><h4>${escapeHtml(record.label)}</h4><p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p></div>
  614. ${badge(meta)}
  615. </div>
  616. <div class="dns-value">
  617. <span>目标值</span>
  618. <code>${escapeHtml(record.value || '')}</code>
  619. <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
  620. </div>
  621. ${current.length ? `<div class="dns-current"><span>当前值</span>${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}</div>` : ''}
  622. ${warnings.length ? `<ul class="inline-warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>` : ''}
  623. </div>
  624. </article>
  625. `;
  626. }
  627. function renderRecordTable(records) {
  628. if (!records.length) return '<p class="text-secondary">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
  629. return `
  630. <div class="record-table-wrap">
  631. <table class="table table-vcenter">
  632. <thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
  633. <tbody>${records.map(renderRecordRow).join('')}</tbody>
  634. </table>
  635. </div>
  636. `;
  637. }
  638. function renderRecordRow(record) {
  639. const meta = statusMeta(record);
  640. return `
  641. <tr>
  642. <td>${escapeHtml(record.label)}</td>
  643. <td class="mono">${escapeHtml(record.host)}</td>
  644. <td>${escapeHtml(record.type)}</td>
  645. <td><code>${escapeHtml(record.value || '')}</code></td>
  646. <td>${badge(meta)}</td>
  647. <td><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
  648. </tr>
  649. `;
  650. }
  651. function renderSettingsForm(domain) {
  652. const dnsOptions = [
  653. '<option value="">不绑定,手动配置</option>',
  654. ...state.dnsCredentials.map((credential) => `<option value="${credential.id}" ${domain.dnsCredentialId === credential.id ? 'selected' : ''}>${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}</option>`)
  655. ].join('');
  656. return `
  657. <form class="compact-form" data-form="settings">
  658. <div class="mb-3">
  659. <label class="form-label">DNS API</label>
  660. <select class="form-select" name="dnsCredentialId">${dnsOptions}</select>
  661. <div class="form-hint">绑定后才能使用一键配置 DNS。</div>
  662. </div>
  663. <div class="row">
  664. <div class="col-sm-6 mb-3">
  665. <label class="form-label">DKIM selector</label>
  666. <input class="form-control" name="selector" value="${escapeAttr(domain.selector)}">
  667. <div class="form-hint">轮换 DKIM 时也会重新生成 selector。</div>
  668. </div>
  669. <div class="col-sm-6 mb-3">
  670. <label class="form-label">DMARC 策略</label>
  671. <select class="form-select" name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
  672. </div>
  673. </div>
  674. <div class="mb-3"><label class="form-label">发信主机</label><input class="form-control" name="senderHost" value="${escapeAttr(domain.senderHost)}"></div>
  675. <div class="mb-3"><label class="form-label">发信 IP</label><input class="form-control" name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></div>
  676. <div class="mb-3"><label class="form-label">兼容第三方 SPF</label><textarea class="form-control" name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></div>
  677. <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>
  678. <button class="btn btn-outline-secondary" type="submit">保存设置</button>
  679. </form>
  680. `;
  681. }
  682. function renderLiveDns(live) {
  683. if (!live) return '<p class="text-secondary">暂无检查数据</p>';
  684. const rows = [['根域 TXT', live.rootTxt], ['验证 TXT', live.verificationTxt], ['DKIM TXT', live.dkimTxt], ['DMARC TXT', live.dmarcTxt], ['发信主机 A', live.senderA], ['发信 IP PTR', live.ptr]];
  685. 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>`;
  686. }
  687. function renderSendForm(domain) {
  688. return `
  689. <form class="send-form" data-form="send">
  690. <div class="mb-3"><label class="form-label">From</label><input class="form-control" name="from" value="noreply@${escapeAttr(domain.domain)}"></div>
  691. <div class="mb-3"><label class="form-label">To</label><input class="form-control" name="to" placeholder="user@example.com" required></div>
  692. <div class="mb-3"><label class="form-label">Subject</label><input class="form-control" name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></div>
  693. <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>
  694. <button class="btn btn-primary" type="submit">发送测试</button>
  695. </form>
  696. `;
  697. }
  698. function renderEvents(domain) {
  699. const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8);
  700. if (!events.length) return '<p class="text-secondary">暂无发送记录</p>';
  701. 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>`;
  702. }
  703. function renderSmtpBox(domain) {
  704. const submission = state.config?.submission;
  705. const credential = state.smtpCredential;
  706. const password = credential?.password || '';
  707. if (!submission?.enabled) return '<p class="text-secondary">SMTP Submission 未启用。</p>';
  708. return `
  709. <div class="smtp-grid">
  710. <div class="smtp-row"><span>Host</span><code>${escapeHtml(submission.host)}</code></div>
  711. <div class="smtp-row"><span>Ports</span>${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}</div>
  712. <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>
  713. <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>
  714. <div class="smtp-row"><span>AUTH</span><code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code></div>
  715. <div class="smtp-row"><span>From</span><code>noreply@${escapeHtml(domain.domain)}</code></div>
  716. </div>
  717. `;
  718. }
  719. function renderOptionalRecords(records) {
  720. if (!records.length) return '<p class="text-secondary">完成基础发信配置后可逐步启用。</p>';
  721. return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
  722. }
  723. function renderWarnings(warnings) {
  724. return `<ul class="warning-list">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`;
  725. }
  726. function apiExample(domain) {
  727. const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
  728. return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
  729. -H 'Authorization: Bearer ${token}' \\
  730. -H 'Content-Type: application/json' \\
  731. -d '{
  732. "from": "noreply@${domain.domain}",
  733. "to": "user@example.com",
  734. "subject": "Hello from MailHub",
  735. "text": "Signed with DKIM and queued by MailHub."
  736. }'`;
  737. }
  738. async function addDomain(event) {
  739. event.preventDefault();
  740. await mutate(async () => {
  741. const data = Object.fromEntries(new FormData(event.target).entries());
  742. const result = await api('/api/domains', { method: 'POST', body: JSON.stringify(data) });
  743. state.domains.unshift(result.domain);
  744. state.selectedId = result.domain.id;
  745. event.target.reset();
  746. renderDefaults();
  747. render();
  748. await checkSelected();
  749. });
  750. }
  751. async function saveSmtpCredential(event) {
  752. event.preventDefault();
  753. await mutate(async () => {
  754. const data = Object.fromEntries(new FormData(event.target).entries());
  755. const result = await api('/api/smtp-credential', { method: 'PUT', body: JSON.stringify(data) });
  756. state.smtpCredential = result.credential;
  757. if (state.config?.submission) {
  758. state.config.submission.username = result.credential.username;
  759. state.config.submission.passwordSet = result.credential.passwordSet;
  760. }
  761. els.smtpPassword.value = '';
  762. renderDefaults();
  763. render();
  764. toast('SMTP 凭据已保存');
  765. });
  766. }
  767. async function saveDnsCredential(event) {
  768. event.preventDefault();
  769. await mutate(async () => {
  770. const data = Object.fromEntries(new FormData(event.target).entries());
  771. const id = Number(data.id || 0);
  772. delete data.id;
  773. const path = id ? `/api/dns-credentials/${id}` : '/api/dns-credentials';
  774. const method = id ? 'PATCH' : 'POST';
  775. const result = await api(path, { method, body: JSON.stringify(data) });
  776. if (id) {
  777. state.dnsCredentials = state.dnsCredentials.map((item) => item.id === id ? result.credential : item);
  778. } else {
  779. state.dnsCredentials.unshift(result.credential);
  780. }
  781. resetDnsCredentialForm();
  782. renderDefaults();
  783. render();
  784. toast(id ? 'DNS API 已更新' : 'DNS API 已新增');
  785. });
  786. }
  787. function editDnsCredential(id) {
  788. const credential = state.dnsCredentials.find((item) => item.id === id);
  789. if (!credential) return;
  790. setView('dns');
  791. resetDnsCredentialForm();
  792. els.dnsCredentialIdField.value = credential.id;
  793. els.dnsCredentialName.value = credential.name || '';
  794. els.dnsProvider.value = credential.provider || 'cloudflare';
  795. els.dnsZoneName.value = credential.zoneName || '';
  796. els.dnsDefaultTtl.value = credential.defaultTtl || 600;
  797. els.dnsCredentialFormTitle.textContent = `编辑 ${credential.name}`;
  798. els.dnsCredentialFormHint.textContent = '密钥字段留空会保留原值。';
  799. toggleDnsProviderFields();
  800. els.dnsCredentialName.focus();
  801. }
  802. function resetDnsCredentialForm() {
  803. els.dnsCredentialForm.reset();
  804. els.dnsCredentialIdField.value = '';
  805. els.dnsDefaultTtl.value = 600;
  806. els.dnsCredentialFormTitle.textContent = '新增 DNS API';
  807. els.dnsCredentialFormHint.textContent = '选择服务商后只填写对应字段。';
  808. toggleDnsProviderFields();
  809. }
  810. function toggleDnsProviderFields() {
  811. const provider = els.dnsProvider.value || 'cloudflare';
  812. for (const fieldset of document.querySelectorAll('[data-provider-field]')) {
  813. fieldset.classList.toggle('hidden', fieldset.dataset.providerField !== provider);
  814. }
  815. }
  816. async function createApiToken(event) {
  817. event.preventDefault();
  818. await mutate(async () => {
  819. const data = Object.fromEntries(new FormData(event.target).entries());
  820. const result = await api('/api/api-tokens', { method: 'POST', body: JSON.stringify(data) });
  821. state.apiTokens.unshift(result.token);
  822. event.target.reset();
  823. renderDefaults();
  824. render();
  825. await navigator.clipboard.writeText(result.token.token);
  826. toast('Token 已生成并复制,请立即保存');
  827. });
  828. }
  829. function generateSmtpPassword() {
  830. const bytes = new Uint8Array(24);
  831. crypto.getRandomValues(bytes);
  832. const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
  833. els.smtpPassword.type = 'text';
  834. els.smtpPassword.value = password;
  835. els.smtpPassword.focus();
  836. els.smtpPassword.select();
  837. }
  838. async function handleGlobalClick(event) {
  839. const viewButton = event.target.closest('[data-view]');
  840. if (viewButton && !event.target.closest('[data-domain-id]')) {
  841. event.preventDefault();
  842. setView(viewButton.dataset.view);
  843. return;
  844. }
  845. const copyButton = event.target.closest('[data-copy]');
  846. if (copyButton) {
  847. event.preventDefault();
  848. const value = copyButton.dataset.copy || '';
  849. if (!value) return;
  850. await navigator.clipboard.writeText(value);
  851. toast('已复制');
  852. return;
  853. }
  854. const actionTarget = event.target.closest('[data-action]');
  855. const action = actionTarget?.dataset.action;
  856. if (!action) return;
  857. const id = Number(actionTarget.closest('[data-id]')?.dataset.id || actionTarget.dataset.id || 0);
  858. if (action === 'check') return checkSelected();
  859. if (action === 'apply-dns') return applyDnsSelected();
  860. if (action === 'copy-all-dns') return copyAllDns();
  861. if (action === 'rotate-dkim') return rotateDkim();
  862. if (action === 'delete-domain') return deleteSelected();
  863. if (action === 'edit-dns') return editDnsCredential(id);
  864. if (action === 'test-dns') return testDnsCredential(id);
  865. if (action === 'delete-dns') return deleteDnsCredential(id);
  866. if (action === 'delete-token') return deleteApiToken(id);
  867. if (action === 'toggle-user') return toggleUser(id, actionTarget.closest('[data-status]')?.dataset.status);
  868. }
  869. async function handleDetailSubmit(event) {
  870. const form = event.target.closest('form[data-form]');
  871. if (!form) return;
  872. event.preventDefault();
  873. if (form.dataset.form === 'settings') return saveSettings(form);
  874. if (form.dataset.form === 'send') return sendTest(form);
  875. if (form.dataset.form === 'admin-settings') return saveAdminSettings(form);
  876. }
  877. async function checkSelected() {
  878. const id = state.selectedId;
  879. if (!id) return;
  880. await mutate(async () => {
  881. const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
  882. replaceDomain(result.domain);
  883. render();
  884. });
  885. }
  886. async function applyDnsSelected() {
  887. const id = state.selectedId;
  888. if (!id) return;
  889. await mutate(async () => {
  890. const result = await api(`/api/domains/${id}/apply-dns`, { method: 'POST' });
  891. replaceDomain(result.domain);
  892. render();
  893. toast(result.apply?.ok ? 'DNS 配置完成' : 'DNS 配置部分失败');
  894. });
  895. }
  896. async function saveSettings(form) {
  897. const id = state.selectedId;
  898. const data = Object.fromEntries(new FormData(form).entries());
  899. await mutate(async () => {
  900. const result = await api(`/api/domains/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
  901. replaceDomain(result.domain);
  902. render();
  903. await checkSelected();
  904. });
  905. }
  906. async function sendTest(form) {
  907. const id = state.selectedId;
  908. const data = Object.fromEntries(new FormData(form).entries());
  909. await mutate(async () => {
  910. await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
  911. const events = await api('/api/events');
  912. state.events = events.events || [];
  913. renderDefaults();
  914. render();
  915. toast('已提交到发信队列');
  916. });
  917. }
  918. async function saveAdminSettings(form) {
  919. const data = Object.fromEntries(new FormData(form).entries());
  920. await mutate(async () => {
  921. const result = await api('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(data) });
  922. state.settings = result.settings;
  923. state.config = { ...state.config, ...result.settings };
  924. renderDefaults();
  925. render();
  926. toast('系统设置已保存');
  927. });
  928. }
  929. async function rotateDkim() {
  930. const id = state.selectedId;
  931. if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
  932. await mutate(async () => {
  933. const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' });
  934. replaceDomain(result.domain);
  935. render();
  936. await checkSelected();
  937. });
  938. }
  939. async function deleteSelected() {
  940. const id = state.selectedId;
  941. const domain = state.domains.find((item) => item.id === id);
  942. if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return;
  943. await mutate(async () => {
  944. await api(`/api/domains/${id}`, { method: 'DELETE' });
  945. state.domains = state.domains.filter((item) => item.id !== id);
  946. state.selectedId = state.domains[0]?.id || null;
  947. renderDefaults();
  948. render();
  949. });
  950. }
  951. async function testDnsCredential(id) {
  952. await mutate(async () => {
  953. const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
  954. toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败');
  955. });
  956. }
  957. async function deleteDnsCredential(id) {
  958. const credential = state.dnsCredentials.find((item) => item.id === id);
  959. if (!confirm(`删除 ${credential?.name || '该 DNS API 凭据'}?`)) return;
  960. await mutate(async () => {
  961. await api(`/api/dns-credentials/${id}`, { method: 'DELETE' });
  962. state.dnsCredentials = state.dnsCredentials.filter((item) => item.id !== id);
  963. state.domains = state.domains.map((domain) => domain.dnsCredentialId === id ? { ...domain, dnsCredentialId: null } : domain);
  964. if (Number(els.dnsCredentialIdField.value || 0) === id) resetDnsCredentialForm();
  965. renderDefaults();
  966. render();
  967. });
  968. }
  969. async function deleteApiToken(id) {
  970. if (!confirm('删除该 API Token?')) return;
  971. await mutate(async () => {
  972. await api(`/api/api-tokens/${id}`, { method: 'DELETE' });
  973. state.apiTokens = state.apiTokens.filter((item) => item.id !== id);
  974. renderDefaults();
  975. render();
  976. });
  977. }
  978. async function toggleUser(id, status) {
  979. const next = status === 'active' ? 'disabled' : 'active';
  980. await mutate(async () => {
  981. const result = await api(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify({ status: next }) });
  982. state.users = state.users.map((user) => user.id === id ? result.user : user);
  983. renderDefaults();
  984. });
  985. }
  986. async function copyAllDns() {
  987. const domain = state.domains.find((item) => item.id === state.selectedId);
  988. const records = domain?.status?.records || [];
  989. if (!records.length) return toast('暂无 DNS 记录');
  990. const text = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
  991. await navigator.clipboard.writeText(text);
  992. toast('已复制全部 DNS 记录');
  993. }
  994. async function logout() {
  995. setBusy(true);
  996. try {
  997. await fetch('/api/logout', { method: 'POST' });
  998. } finally {
  999. window.location.href = '/login';
  1000. }
  1001. }
  1002. async function mutate(fn) {
  1003. setBusy(true);
  1004. try {
  1005. await fn();
  1006. } catch (error) {
  1007. toast(error.message, 'error');
  1008. } finally {
  1009. setBusy(false);
  1010. }
  1011. }
  1012. async function api(path, options = {}) {
  1013. const response = await fetch(path, {
  1014. ...options,
  1015. headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
  1016. });
  1017. const text = await response.text();
  1018. const payload = text ? JSON.parse(text) : {};
  1019. if (!response.ok) {
  1020. if (response.status === 401) {
  1021. window.location.href = '/login';
  1022. return {};
  1023. }
  1024. throw new Error(payload.error || `HTTP ${response.status}`);
  1025. }
  1026. return payload;
  1027. }
  1028. function replaceDomain(domain) {
  1029. state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
  1030. }
  1031. function statusMeta(target) {
  1032. if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
  1033. if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
  1034. if (target.status === 'pending') return { className: 'pending', label: '传播中' };
  1035. if (target.status === 'missing') return { className: 'missing', label: '缺失' };
  1036. if (target.status === 'warn') return { className: 'warn', label: '需调整' };
  1037. return { className: 'warn', label: '待配置' };
  1038. }
  1039. function badge(meta) {
  1040. return `<span class="badge ${badgeTone(meta.className)}">${escapeHtml(meta.label)}</span>`;
  1041. }
  1042. function badgeTone(className) {
  1043. return {
  1044. ok: 'bg-green-lt',
  1045. pending: 'bg-blue-lt',
  1046. warn: 'bg-yellow-lt',
  1047. missing: 'bg-red-lt',
  1048. failed: 'bg-red-lt',
  1049. idle: 'bg-secondary-lt'
  1050. }[className] || 'bg-secondary-lt';
  1051. }
  1052. function providerLabel(provider) {
  1053. return { cloudflare: 'Cloudflare', aliyun: '阿里云 DNS', dnspod: '腾讯云 DNSPod' }[provider] || provider;
  1054. }
  1055. function setBusy(value) {
  1056. state.busy = value;
  1057. document.body.classList.toggle('busy', value);
  1058. els.refreshButton.disabled = value;
  1059. if (els.refreshButtonMobile) els.refreshButtonMobile.disabled = value;
  1060. }
  1061. function toast(message, tone = 'info') {
  1062. const node = document.createElement('div');
  1063. node.className = `app-toast ${tone}`;
  1064. node.textContent = message;
  1065. els.toastHost.appendChild(node);
  1066. setTimeout(() => node.remove(), 3200);
  1067. }
  1068. function formatDate(value) {
  1069. if (!value) return '';
  1070. return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
  1071. }
  1072. function escapeHtml(value) {
  1073. return String(value ?? '')
  1074. .replace(/&/g, '&amp;')
  1075. .replace(/</g, '&lt;')
  1076. .replace(/>/g, '&gt;')
  1077. .replace(/"/g, '&quot;')
  1078. .replace(/'/g, '&#039;');
  1079. }
  1080. function escapeAttr(value) {
  1081. return escapeHtml(value).replace(/`/g, '&#096;');
  1082. }