| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779 |
- const state = {
- config: null,
- smtpCredential: null,
- domains: [],
- events: [],
- selectedId: null,
- busy: false
- };
- const els = {
- runtimeLine: document.querySelector('#runtimeLine'),
- securityNotice: document.querySelector('#securityNotice'),
- addDomainForm: document.querySelector('#addDomainForm'),
- smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
- smtpCredentialState: document.querySelector('#smtpCredentialState'),
- smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
- smtpUsername: document.querySelector('#smtpUsername'),
- smtpPassword: document.querySelector('#smtpPassword'),
- generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
- defaultSenderHost: document.querySelector('#defaultSenderHost'),
- defaultSendingIp: document.querySelector('#defaultSendingIp'),
- defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
- domainList: document.querySelector('#domainList'),
- domainCount: document.querySelector('#domainCount'),
- detailPanel: document.querySelector('#detailPanel'),
- refreshButton: document.querySelector('#refreshButton'),
- logoutButton: document.querySelector('#logoutButton')
- };
- init();
- async function init() {
- bindEvents();
- await refreshAll();
- }
- function bindEvents() {
- els.refreshButton.addEventListener('click', refreshAll);
- els.logoutButton.addEventListener('click', logout);
- document.addEventListener('click', handleCopyClick);
- els.addDomainForm.addEventListener('submit', addDomain);
- els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
- els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
- els.domainList.addEventListener('click', async (event) => {
- const item = event.target.closest('[data-domain-id]');
- if (!item) return;
- state.selectedId = Number(item.dataset.domainId);
- render();
- });
- els.detailPanel.addEventListener('click', handleDetailClick);
- els.detailPanel.addEventListener('submit', handleDetailSubmit);
- }
- async function refreshAll() {
- setBusy(true);
- try {
- const [config, domains, events, smtpCredential] = await Promise.all([
- api('/api/config'),
- api('/api/domains'),
- api('/api/events'),
- api('/api/smtp-credential')
- ]);
- state.config = config;
- state.domains = domains.domains || [];
- state.events = events.events || [];
- state.smtpCredential = smtpCredential.credential || null;
- if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
- renderDefaults();
- render();
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- function renderDefaults() {
- if (!state.config) return;
- els.runtimeLine.textContent = `${state.config.mailHostname} / ${state.config.sendingIp || '未设置发信 IP'}`;
- els.defaultSenderHost.value = state.config.mailHostname || '';
- els.defaultSendingIp.value = state.config.sendingIp || '';
- els.defaultSpfExtra.value = state.config.defaultSpfMechanisms || '';
- els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || '';
- els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置';
- els.smtpCredentialState.className = `badge ${state.smtpCredential?.passwordSet ? 'ok' : 'warn'}`;
- renderSmtpCredentialCopy();
- els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
- els.securityNotice.textContent = state.config.usingDefaultAdminPassword
- ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
- : '';
- }
- function renderSmtpCredentialCopy() {
- if (!els.smtpCredentialCopy) return;
- const credential = state.smtpCredential;
- if (!credential?.username && !credential?.passwordSet) {
- els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
- return;
- }
- const password = credential?.password || '';
- els.smtpCredentialCopy.innerHTML = `
- <div class="copy-row">
- <span>Username</span>
- <code>${escapeHtml(credential.username || '')}</code>
- <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button" ${credential.username ? '' : 'disabled'}>复制</button>
- </div>
- <div class="copy-row">
- <span>Password</span>
- ${password
- ? `<code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button>`
- : '<p class="muted">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
- </div>
- `;
- }
- function render() {
- els.domainCount.textContent = String(state.domains.length);
- renderDomainList();
- renderDetail();
- }
- function renderDomainList() {
- if (!state.domains.length) {
- els.domainList.innerHTML = '<p class="muted">暂无域名</p>';
- return;
- }
- els.domainList.innerHTML = state.domains.map((domain) => {
- const status = statusMeta(domain.status);
- return `
- <button class="domain-item ${domain.id === state.selectedId ? 'active' : ''}" data-domain-id="${domain.id}" type="button">
- <div class="item-line">
- <strong>${escapeHtml(domain.domain)}</strong>
- <span class="badge ${status.className}">${status.label}</span>
- </div>
- <span class="muted">${escapeHtml(domain.selector)}._domainkey</span>
- </button>
- `;
- }).join('');
- }
- function renderDetail() {
- const domain = state.domains.find((item) => item.id === state.selectedId);
- if (!domain) {
- els.detailPanel.innerHTML = `
- <div class="empty-state">
- <h2>选择或添加一个域名</h2>
- <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
- </div>
- `;
- return;
- }
- const guide = domain.status || {};
- const records = guide.records || [];
- const warnings = guide.warnings || [];
- const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
- els.detailPanel.innerHTML = `
- <div class="detail-header">
- <div class="detail-title">
- <h2>${escapeHtml(domain.domain)}</h2>
- <div class="status-strip">
- ${badge(statusMeta(guide))}
- <span class="badge idle">最近检查 ${escapeHtml(checkedAt)}</span>
- <span class="badge idle">Selector ${escapeHtml(domain.selector)}</span>
- </div>
- </div>
- <div class="detail-actions">
- <button class="primary-button" data-action="check" type="button">立即检查</button>
- <button class="secondary-button" data-action="rotate-dkim" type="button">轮换 DKIM</button>
- <button class="danger-button" data-action="delete-domain" type="button">删除</button>
- </div>
- </div>
- <div class="detail-body">
- ${renderSetupOverview(domain, guide, records)}
- ${warnings.length ? renderWarnings(warnings) : ''}
- <div class="grid-two">
- <section class="subpanel">
- <div class="subpanel-head">
- <h3>配置引导</h3>
- <button class="small-button" data-action="copy-all-dns" type="button">复制全部</button>
- </div>
- ${renderDnsGuide(records)}
- </section>
- <section class="subpanel">
- <h3>域名设置</h3>
- ${renderSettingsForm(domain)}
- </section>
- </div>
- <div class="grid-two">
- <section class="subpanel">
- <h3>当前 DNS</h3>
- ${renderLiveDns(guide.live)}
- </section>
- <section class="subpanel">
- <h3>测试发送</h3>
- ${renderSendForm(domain)}
- </section>
- </div>
- <div class="grid-two">
- <section class="subpanel api-box">
- <h3>发送 API</h3>
- <pre><code>${escapeHtml(apiExample(domain))}</code></pre>
- </section>
- <section class="subpanel">
- <h3>SMTP 发信</h3>
- ${renderSmtpBox(domain)}
- </section>
- </div>
- <section class="subpanel">
- <h3>最近发送</h3>
- ${renderEvents(domain)}
- </section>
- <section class="subpanel">
- <h3>增强安全记录</h3>
- ${renderOptionalRecords(guide.optionalRecords || [])}
- </section>
- </div>
- `;
- }
- function renderSetupOverview(domain, guide, records) {
- const important = [
- ['verification', '域名验证'],
- ['dkim', 'DKIM'],
- ['spf', 'SPF'],
- ['dmarc', 'DMARC'],
- ['ptr', 'PTR']
- ];
- const cards = important.map(([key, label]) => {
- const record = records.find((item) => item.key === key);
- const meta = statusMeta(record || {});
- return `
- <div class="metric-card">
- <span>${escapeHtml(label)}</span>
- <strong>${record ? escapeHtml(meta.label) : '待生成'}</strong>
- </div>
- `;
- }).join('');
- return `
- <section class="guide-hero">
- <div>
- <span class="eyebrow">Sending domain</span>
- <h3>${escapeHtml(domain.domain)}</h3>
- <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}</p>
- </div>
- <div class="summary-grid">${cards}</div>
- </section>
- `;
- }
- function renderDnsGuide(records) {
- if (!records.length) {
- return `
- <div class="empty-guide">
- <h4>尚未生成检查结果</h4>
- <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
- </div>
- `;
- }
- return `
- <div class="record-steps">
- ${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}
- </div>
- `;
- }
- function renderRecordCard(record, index) {
- const meta = statusMeta(record);
- const current = Array.isArray(record.current) ? record.current : (record.current ? [record.current] : []);
- const warnings = record.warnings || [];
- return `
- <article class="record-card ${meta.className}">
- <div class="record-step">
- <span>${index}</span>
- </div>
- <div class="record-card-body">
- <div class="record-card-title">
- <div>
- <h4>${escapeHtml(record.label)}</h4>
- <p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p>
- </div>
- ${badge(meta)}
- </div>
- <div class="dns-value">
- <span>目标值</span>
- <code>${escapeHtml(record.value || '')}</code>
- <button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
- </div>
- ${current.length ? `
- <div class="dns-current">
- <span>当前值</span>
- ${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}
- </div>
- ` : ''}
- ${warnings.length ? `
- <ul class="inline-warnings">
- ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
- </ul>
- ` : ''}
- </div>
- </article>
- `;
- }
- function renderRecordTable(records) {
- if (!records.length) {
- return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
- }
- return `
- <div class="record-table-wrap">
- <table>
- <thead>
- <tr>
- <th>项目</th>
- <th>主机</th>
- <th>类型</th>
- <th>目标值</th>
- <th>状态</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- ${records.map(renderRecordRow).join('')}
- </tbody>
- </table>
- </div>
- `;
- }
- function renderRecordRow(record) {
- const meta = statusMeta(record);
- return `
- <tr>
- <td>${escapeHtml(record.label)}</td>
- <td class="mono">${escapeHtml(record.host)}</td>
- <td>${escapeHtml(record.type)}</td>
- <td><code>${escapeHtml(record.value || '')}</code></td>
- <td>${badge(meta)}</td>
- <td><button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
- </tr>
- `;
- }
- function renderSettingsForm(domain) {
- return `
- <form class="compact-form" data-form="settings">
- <div class="form-grid">
- <label>
- DKIM selector
- <input name="selector" value="${escapeAttr(domain.selector)}">
- </label>
- <label>
- DMARC 策略
- <select name="dmarcPolicy">
- ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
- </select>
- </label>
- </div>
- <label>
- 发信主机
- <input name="senderHost" value="${escapeAttr(domain.senderHost)}">
- </label>
- <label>
- 发信 IP
- <input name="sendingIp" value="${escapeAttr(domain.sendingIp)}">
- </label>
- <label>
- 兼容第三方 SPF
- <textarea name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea>
- </label>
- <label>
- DMARC rua
- <input name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
- </label>
- <button class="secondary-button" type="submit">保存设置</button>
- </form>
- `;
- }
- function renderLiveDns(live) {
- if (!live) return '<p class="muted">暂无检查数据</p>';
- const rows = [
- ['根域 TXT', live.rootTxt],
- ['验证 TXT', live.verificationTxt],
- ['DKIM TXT', live.dkimTxt],
- ['DMARC TXT', live.dmarcTxt],
- ['发信主机 A', live.senderA],
- ['发信 IP PTR', live.ptr]
- ];
- 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="muted">未发现</p>'}
- </div>
- `).join('')}
- </div>
- `;
- }
- function renderSendForm(domain) {
- return `
- <form class="send-form" data-form="send">
- <label>
- From
- <input name="from" value="noreply@${escapeAttr(domain.domain)}">
- </label>
- <label>
- To
- <input name="to" placeholder="user@example.com" required>
- </label>
- <label>
- Subject
- <input name="subject" value="MailHub test for ${escapeAttr(domain.domain)}">
- </label>
- <label>
- Text
- <textarea name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea>
- </label>
- <button class="primary-button" type="submit">发送测试</button>
- </form>
- `;
- }
- function renderEvents(domain) {
- const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8);
- if (!events.length) return '<p class="muted">暂无发送记录</p>';
- 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="muted">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span>
- <span class="muted">${escapeHtml(formatDate(event.createdAt))}</span>
- </div>
- `).join('')}
- </div>
- `;
- }
- function renderSmtpBox(domain) {
- const submission = state.config?.submission;
- const credential = state.smtpCredential;
- const password = credential?.password || '';
- if (!submission?.enabled) {
- return '<p class="muted">SMTP Submission 未启用。</p>';
- }
- return `
- <div class="smtp-grid">
- <div class="smtp-row">
- <span>Host</span>
- <code>${escapeHtml(submission.host)}</code>
- </div>
- <div class="smtp-row">
- <span>Ports</span>
- ${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}
- </div>
- <div class="smtp-row">
- <span>Username</span>
- <div class="copy-row inline">
- <code>${escapeHtml(credential?.username || submission.username || '')}</code>
- <button class="small-button" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button>
- </div>
- </div>
- <div class="smtp-row">
- <span>Password</span>
- ${password
- ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button></div>`
- : '<p class="muted">旧密码无法回显,请在左侧重新设置一次新密码后复制。</p>'}
- </div>
- <div class="smtp-row">
- <span>AUTH</span>
- <code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code>
- </div>
- <div class="smtp-row">
- <span>From</span>
- <code>noreply@${escapeHtml(domain.domain)}</code>
- </div>
- </div>
- `;
- }
- function renderOptionalRecords(records) {
- if (!records.length) return '<p class="muted">完成基础发信配置后可逐步启用。</p>';
- return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
- }
- function renderWarnings(warnings) {
- return `
- <ul class="warning-list">
- ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
- </ul>
- `;
- }
- function apiExample(domain) {
- return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
- -H 'Authorization: Bearer <API_TOKEN>' \\
- -H 'Content-Type: application/json' \\
- -d '{
- "from": "noreply@${domain.domain}",
- "to": "user@example.com",
- "subject": "Hello from MailHub",
- "text": "Signed with DKIM and queued by Postfix."
- }'`;
- }
- async function addDomain(event) {
- event.preventDefault();
- const data = Object.fromEntries(new FormData(event.target).entries());
- setBusy(true);
- try {
- const result = await api('/api/domains', {
- method: 'POST',
- body: JSON.stringify(data)
- });
- state.domains.unshift(result.domain);
- state.selectedId = result.domain.id;
- event.target.reset();
- renderDefaults();
- render();
- await checkSelected();
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- async function saveSmtpCredential(event) {
- event.preventDefault();
- const data = Object.fromEntries(new FormData(event.target).entries());
- setBusy(true);
- try {
- const result = await api('/api/smtp-credential', {
- method: 'PUT',
- body: JSON.stringify(data)
- });
- state.smtpCredential = result.credential;
- if (state.config?.submission) {
- state.config.submission.username = result.credential.username;
- state.config.submission.passwordSet = result.credential.passwordSet;
- }
- els.smtpPassword.value = '';
- renderDefaults();
- render();
- toast('SMTP 凭据已保存');
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- function generateSmtpPassword() {
- const bytes = new Uint8Array(24);
- crypto.getRandomValues(bytes);
- const password = btoa(String.fromCharCode(...bytes))
- .replace(/[+/=]/g, '')
- .slice(0, 28);
- els.smtpPassword.type = 'text';
- els.smtpPassword.value = password;
- els.smtpPassword.focus();
- els.smtpPassword.select();
- }
- async function handleDetailClick(event) {
- const action = event.target.closest('[data-action]')?.dataset.action;
- if (!action) return;
- if (action === 'check') return checkSelected();
- if (action === 'copy-all-dns') return copyAllDns();
- if (action === 'rotate-dkim') return rotateDkim();
- if (action === 'delete-domain') return deleteSelected();
- }
- async function handleCopyClick(event) {
- const button = event.target.closest('[data-copy]');
- if (!button) return;
- event.preventDefault();
- const value = button.dataset.copy || '';
- if (!value) return;
- await navigator.clipboard.writeText(value);
- toast('已复制');
- }
- async function handleDetailSubmit(event) {
- const form = event.target.closest('form[data-form]');
- if (!form) return;
- event.preventDefault();
- if (form.dataset.form === 'settings') return saveSettings(form);
- if (form.dataset.form === 'send') return sendTest(form);
- }
- async function checkSelected() {
- const id = state.selectedId;
- if (!id) return;
- setBusy(true);
- try {
- const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
- replaceDomain(result.domain);
- render();
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- async function saveSettings(form) {
- const id = state.selectedId;
- const data = Object.fromEntries(new FormData(form).entries());
- setBusy(true);
- try {
- const result = await api(`/api/domains/${id}`, {
- method: 'PATCH',
- body: JSON.stringify(data)
- });
- replaceDomain(result.domain);
- render();
- await checkSelected();
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- async function sendTest(form) {
- const id = state.selectedId;
- const data = Object.fromEntries(new FormData(form).entries());
- setBusy(true);
- try {
- await api(`/api/domains/${id}/test-send`, {
- method: 'POST',
- body: JSON.stringify(data)
- });
- const events = await api('/api/events');
- state.events = events.events || [];
- render();
- toast('已提交到 Postfix 队列');
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- async function rotateDkim() {
- const id = state.selectedId;
- if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
- setBusy(true);
- try {
- const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' });
- replaceDomain(result.domain);
- render();
- await checkSelected();
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- async function deleteSelected() {
- const id = state.selectedId;
- const domain = state.domains.find((item) => item.id === id);
- if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return;
- setBusy(true);
- try {
- await api(`/api/domains/${id}`, { method: 'DELETE' });
- state.domains = state.domains.filter((item) => item.id !== id);
- state.selectedId = state.domains[0]?.id || null;
- render();
- } catch (error) {
- toast(error.message);
- } finally {
- setBusy(false);
- }
- }
- async function copyAllDns() {
- const domain = state.domains.find((item) => item.id === state.selectedId);
- const records = domain?.status?.records || [];
- if (!records.length) return toast('暂无 DNS 记录');
- const text = records
- .map((record) => `${record.host}\t${record.type}\t${record.value || ''}`)
- .join('\n');
- await navigator.clipboard.writeText(text);
- toast('已复制全部 DNS 记录');
- }
- async function logout() {
- setBusy(true);
- try {
- await fetch('/api/logout', { method: 'POST' });
- } finally {
- window.location.href = '/login';
- }
- }
- async function api(path, options = {}) {
- const response = await fetch(path, {
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...(options.headers || {})
- }
- });
- const text = await response.text();
- const payload = text ? JSON.parse(text) : {};
- if (!response.ok) {
- if (response.status === 401) {
- window.location.href = '/login';
- return {};
- }
- throw new Error(payload.error || `HTTP ${response.status}`);
- }
- return payload;
- }
- function replaceDomain(domain) {
- state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
- }
- function statusMeta(target) {
- if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
- if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
- if (target.status === 'missing') return { className: 'missing', label: '缺失' };
- if (target.status === 'warn') return { className: 'warn', label: '需调整' };
- return { className: 'warn', label: '待配置' };
- }
- function badge(meta) {
- return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
- }
- function setBusy(value) {
- state.busy = value;
- document.body.classList.toggle('busy', value);
- els.refreshButton.disabled = value;
- }
- function toast(message) {
- const node = document.createElement('div');
- node.className = 'notice';
- node.textContent = message;
- node.style.position = 'fixed';
- node.style.right = '18px';
- node.style.bottom = '18px';
- node.style.zIndex = '10';
- document.body.appendChild(node);
- setTimeout(() => node.remove(), 2800);
- }
- function formatDate(value) {
- if (!value) return '';
- return new Intl.DateTimeFormat('zh-CN', {
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- }).format(new Date(value));
- }
- function escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
- function escapeAttr(value) {
- return escapeHtml(value).replace(/`/g, '`');
- }
|