|
|
@@ -1,8 +1,13 @@
|
|
|
const state = {
|
|
|
+ me: null,
|
|
|
config: null,
|
|
|
smtpCredential: null,
|
|
|
+ dnsCredentials: [],
|
|
|
+ apiTokens: [],
|
|
|
domains: [],
|
|
|
events: [],
|
|
|
+ users: [],
|
|
|
+ settings: null,
|
|
|
selectedId: null,
|
|
|
busy: false
|
|
|
};
|
|
|
@@ -10,19 +15,29 @@ const state = {
|
|
|
const els = {
|
|
|
runtimeLine: document.querySelector('#runtimeLine'),
|
|
|
securityNotice: document.querySelector('#securityNotice'),
|
|
|
+ accountBox: document.querySelector('#accountBox'),
|
|
|
+ userRole: document.querySelector('#userRole'),
|
|
|
addDomainForm: document.querySelector('#addDomainForm'),
|
|
|
+ domainDnsCredential: document.querySelector('#domainDnsCredential'),
|
|
|
smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
|
|
|
smtpCredentialState: document.querySelector('#smtpCredentialState'),
|
|
|
smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
|
|
|
smtpUsername: document.querySelector('#smtpUsername'),
|
|
|
smtpPassword: document.querySelector('#smtpPassword'),
|
|
|
generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
|
|
|
+ dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
|
|
|
+ dnsCredentialList: document.querySelector('#dnsCredentialList'),
|
|
|
+ dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
|
|
|
+ apiTokenForm: document.querySelector('#apiTokenForm'),
|
|
|
+ apiTokenList: document.querySelector('#apiTokenList'),
|
|
|
+ apiTokenCount: document.querySelector('#apiTokenCount'),
|
|
|
defaultSenderHost: document.querySelector('#defaultSenderHost'),
|
|
|
defaultSendingIp: document.querySelector('#defaultSendingIp'),
|
|
|
defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
|
|
|
domainList: document.querySelector('#domainList'),
|
|
|
domainCount: document.querySelector('#domainCount'),
|
|
|
detailPanel: document.querySelector('#detailPanel'),
|
|
|
+ adminPanel: document.querySelector('#adminPanel'),
|
|
|
refreshButton: document.querySelector('#refreshButton'),
|
|
|
logoutButton: document.querySelector('#logoutButton')
|
|
|
};
|
|
|
@@ -37,34 +52,54 @@ async function init() {
|
|
|
function bindEvents() {
|
|
|
els.refreshButton.addEventListener('click', refreshAll);
|
|
|
els.logoutButton.addEventListener('click', logout);
|
|
|
- document.addEventListener('click', handleCopyClick);
|
|
|
+ document.addEventListener('click', handleGlobalClick);
|
|
|
els.addDomainForm.addEventListener('submit', addDomain);
|
|
|
els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
|
|
|
els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
|
|
|
- els.domainList.addEventListener('click', async (event) => {
|
|
|
+ els.dnsCredentialForm.addEventListener('submit', saveDnsCredential);
|
|
|
+ els.apiTokenForm.addEventListener('submit', createApiToken);
|
|
|
+ els.domainList.addEventListener('click', (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);
|
|
|
+ els.adminPanel.addEventListener('submit', handleDetailSubmit);
|
|
|
}
|
|
|
|
|
|
async function refreshAll() {
|
|
|
setBusy(true);
|
|
|
try {
|
|
|
- const [config, domains, events, smtpCredential] = await Promise.all([
|
|
|
+ const me = await api('/api/me');
|
|
|
+ state.me = me.user;
|
|
|
+ const baseCalls = [
|
|
|
api('/api/config'),
|
|
|
api('/api/domains'),
|
|
|
api('/api/events'),
|
|
|
- api('/api/smtp-credential')
|
|
|
- ]);
|
|
|
+ api('/api/smtp-credential'),
|
|
|
+ api('/api/dns-credentials'),
|
|
|
+ api('/api/api-tokens')
|
|
|
+ ];
|
|
|
+ const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
|
|
|
state.config = config;
|
|
|
state.domains = domains.domains || [];
|
|
|
state.events = events.events || [];
|
|
|
state.smtpCredential = smtpCredential.credential || null;
|
|
|
+ state.dnsCredentials = dnsCredentials.credentials || [];
|
|
|
+ state.apiTokens = apiTokens.tokens || [];
|
|
|
+ if (state.me?.role === 'admin') {
|
|
|
+ const [settings, users] = await Promise.all([
|
|
|
+ api('/api/admin/settings'),
|
|
|
+ api('/api/admin/users')
|
|
|
+ ]);
|
|
|
+ state.settings = settings.settings || null;
|
|
|
+ state.users = users.users || [];
|
|
|
+ }
|
|
|
if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
|
|
|
+ if (state.selectedId && !state.domains.some((domain) => domain.id === state.selectedId)) {
|
|
|
+ state.selectedId = state.domains[0]?.id || null;
|
|
|
+ }
|
|
|
renderDefaults();
|
|
|
render();
|
|
|
} catch (error) {
|
|
|
@@ -83,15 +118,42 @@ function renderDefaults() {
|
|
|
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'}`;
|
|
|
+ els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户';
|
|
|
+ els.userRole.className = `badge ${state.me?.role === 'admin' ? 'ok' : 'idle'}`;
|
|
|
+ els.accountBox.innerHTML = `
|
|
|
+ <div class="live-row"><span>Username</span><code>${escapeHtml(state.me?.username || '')}</code></div>
|
|
|
+ <div class="live-row"><span>Email</span><code>${escapeHtml(state.me?.email || '')}</code></div>
|
|
|
+ `;
|
|
|
+ renderDnsOptions();
|
|
|
renderSmtpCredentialCopy();
|
|
|
+ renderDnsCredentials();
|
|
|
+ renderApiTokens();
|
|
|
+ renderAdminPanel();
|
|
|
els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
|
|
|
els.securityNotice.textContent = state.config.usingDefaultAdminPassword
|
|
|
? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
|
|
|
: '';
|
|
|
}
|
|
|
|
|
|
+function render() {
|
|
|
+ els.domainCount.textContent = String(state.domains.length);
|
|
|
+ renderDomainList();
|
|
|
+ renderDetail();
|
|
|
+}
|
|
|
+
|
|
|
+function renderDnsOptions(selectedId = '') {
|
|
|
+ const options = [
|
|
|
+ '<option value="">不绑定,手动配置</option>',
|
|
|
+ ...state.dnsCredentials.map((credential) => `
|
|
|
+ <option value="${credential.id}" ${String(selectedId) === String(credential.id) ? 'selected' : ''}>
|
|
|
+ ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}
|
|
|
+ </option>
|
|
|
+ `)
|
|
|
+ ].join('');
|
|
|
+ els.domainDnsCredential.innerHTML = options;
|
|
|
+}
|
|
|
+
|
|
|
function renderSmtpCredentialCopy() {
|
|
|
- if (!els.smtpCredentialCopy) return;
|
|
|
const credential = state.smtpCredential;
|
|
|
if (!credential?.username && !credential?.passwordSet) {
|
|
|
els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
|
|
|
@@ -102,7 +164,7 @@ function renderSmtpCredentialCopy() {
|
|
|
<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>
|
|
|
+ <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
|
|
|
</div>
|
|
|
<div class="copy-row">
|
|
|
<span>Password</span>
|
|
|
@@ -113,10 +175,91 @@ function renderSmtpCredentialCopy() {
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
-function render() {
|
|
|
- els.domainCount.textContent = String(state.domains.length);
|
|
|
- renderDomainList();
|
|
|
- renderDetail();
|
|
|
+function renderDnsCredentials() {
|
|
|
+ els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
|
|
|
+ if (!state.dnsCredentials.length) {
|
|
|
+ els.dnsCredentialList.innerHTML = '<p class="muted">暂无 DNS API 凭据</p>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
|
|
|
+ <div class="mini-row">
|
|
|
+ <div>
|
|
|
+ <strong>${escapeHtml(credential.name)}</strong>
|
|
|
+ <span class="muted">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}</span>
|
|
|
+ </div>
|
|
|
+ <div class="button-row">
|
|
|
+ <button class="small-button" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
|
|
|
+ <button class="danger-button small-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `).join('');
|
|
|
+}
|
|
|
+
|
|
|
+function renderApiTokens() {
|
|
|
+ els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
|
+ if (!state.apiTokens.length) {
|
|
|
+ els.apiTokenList.innerHTML = '<p class="muted">暂无发送 Token</p>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
|
|
|
+ <div class="mini-row">
|
|
|
+ <div>
|
|
|
+ <strong>${escapeHtml(token.name)}</strong>
|
|
|
+ <span class="muted">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
|
|
|
+ </div>
|
|
|
+ <button class="danger-button small-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
|
|
|
+ </div>
|
|
|
+ `).join('');
|
|
|
+}
|
|
|
+
|
|
|
+function renderAdminPanel() {
|
|
|
+ if (state.me?.role !== 'admin') {
|
|
|
+ els.adminPanel.classList.add('hidden');
|
|
|
+ els.adminPanel.innerHTML = '';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const settings = state.settings || state.config || {};
|
|
|
+ els.adminPanel.classList.remove('hidden');
|
|
|
+ els.adminPanel.innerHTML = `
|
|
|
+ <form class="compact-form" data-form="admin-settings">
|
|
|
+ <div class="section-head">
|
|
|
+ <h2>系统设置</h2>
|
|
|
+ <span class="count">${state.users.length}</span>
|
|
|
+ </div>
|
|
|
+ <label>APP Base URL<input name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}"></label>
|
|
|
+ <label>发信主机<input name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}"></label>
|
|
|
+ <label>发信 IP<input name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}"></label>
|
|
|
+ <label>默认 SPF<textarea name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea></label>
|
|
|
+ <div class="form-grid">
|
|
|
+ <label>DMARC
|
|
|
+ <select name="dmarcPolicy">
|
|
|
+ ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ <label>验证后发信
|
|
|
+ <select name="sendRequiresVerified">
|
|
|
+ <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
|
|
|
+ <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}"></label>
|
|
|
+ <button class="primary-button" type="submit">保存系统设置</button>
|
|
|
+ </form>
|
|
|
+ <div class="mini-list user-mini-list">
|
|
|
+ ${state.users.slice(0, 8).map((user) => `
|
|
|
+ <div class="mini-row">
|
|
|
+ <div>
|
|
|
+ <strong>${escapeHtml(user.username)}</strong>
|
|
|
+ <span class="muted">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
|
|
|
+ </div>
|
|
|
+ <button class="${user.status === 'active' ? 'danger-button' : 'small-button'} small-danger" data-action="toggle-user" data-id="${user.id}" data-status="${user.status}" type="button">
|
|
|
+ ${user.status === 'active' ? '禁用' : '启用'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ `).join('')}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
}
|
|
|
|
|
|
function renderDomainList() {
|
|
|
@@ -164,13 +307,15 @@ function renderDetail() {
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="detail-actions">
|
|
|
- <button class="primary-button" data-action="check" type="button">立即检查</button>
|
|
|
+ <button class="primary-button" data-action="apply-dns" type="button">一键配置 DNS</button>
|
|
|
+ <button class="secondary-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)}
|
|
|
+ ${guide.apply ? renderApplyResult(guide.apply) : ''}
|
|
|
${warnings.length ? renderWarnings(warnings) : ''}
|
|
|
<div class="grid-two">
|
|
|
<section class="subpanel">
|
|
|
@@ -218,49 +363,54 @@ function renderDetail() {
|
|
|
}
|
|
|
|
|
|
function renderSetupOverview(domain, guide, records) {
|
|
|
- const important = [
|
|
|
- ['verification', '域名验证'],
|
|
|
- ['dkim', 'DKIM'],
|
|
|
- ['spf', 'SPF'],
|
|
|
- ['dmarc', 'DMARC'],
|
|
|
- ['ptr', 'PTR']
|
|
|
- ];
|
|
|
+ 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>
|
|
|
- `;
|
|
|
+ return `<div class="metric-card"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
|
|
|
}).join('');
|
|
|
+ const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
|
|
|
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>
|
|
|
+ <p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
|
|
|
</div>
|
|
|
<div class="summary-grid">${cards}</div>
|
|
|
</section>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
+function renderApplyResult(apply) {
|
|
|
+ const rows = apply.results || [];
|
|
|
+ return `
|
|
|
+ <section class="subpanel">
|
|
|
+ <div class="subpanel-head">
|
|
|
+ <h3>一键配置结果</h3>
|
|
|
+ <span class="badge ${apply.ok ? 'ok' : 'warn'}">${apply.ok ? '完成' : '部分失败'}</span>
|
|
|
+ </div>
|
|
|
+ <div class="mini-list">
|
|
|
+ ${rows.map((row) => `
|
|
|
+ <div class="mini-row">
|
|
|
+ <div>
|
|
|
+ <strong>${escapeHtml(row.key)} · ${escapeHtml(row.type)}</strong>
|
|
|
+ <span class="muted">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
|
|
|
+ </div>
|
|
|
+ ${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
|
|
|
+ </div>
|
|
|
+ `).join('')}
|
|
|
+ </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="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>
|
|
|
- `;
|
|
|
+ return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
|
|
|
}
|
|
|
|
|
|
function renderRecordCard(record, index) {
|
|
|
@@ -269,15 +419,10 @@ function renderRecordCard(record, index) {
|
|
|
const warnings = record.warnings || [];
|
|
|
return `
|
|
|
<article class="record-card ${meta.className}">
|
|
|
- <div class="record-step">
|
|
|
- <span>${index}</span>
|
|
|
- </div>
|
|
|
+ <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>
|
|
|
+ <div><h4>${escapeHtml(record.label)}</h4><p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p></div>
|
|
|
${badge(meta)}
|
|
|
</div>
|
|
|
<div class="dns-value">
|
|
|
@@ -285,42 +430,20 @@ function renderRecordCard(record, index) {
|
|
|
<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>
|
|
|
- ` : ''}
|
|
|
+ ${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>';
|
|
|
- }
|
|
|
+ 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>
|
|
|
+ <thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
|
|
|
+ <tbody>${records.map(renderRecordRow).join('')}</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
`;
|
|
|
@@ -341,36 +464,23 @@ function renderRecordRow(record) {
|
|
|
}
|
|
|
|
|
|
function renderSettingsForm(domain) {
|
|
|
+ const dnsOptions = [
|
|
|
+ '<option value="">不绑定</option>',
|
|
|
+ ...state.dnsCredentials.map((credential) => `<option value="${credential.id}" ${domain.dnsCredentialId === credential.id ? 'selected' : ''}>${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}</option>`)
|
|
|
+ ].join('');
|
|
|
return `
|
|
|
<form class="compact-form" data-form="settings">
|
|
|
+ <label>DNS API<select name="dnsCredentialId">${dnsOptions}</select></label>
|
|
|
<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>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>
|
|
|
+ <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>
|
|
|
`;
|
|
|
@@ -378,45 +488,17 @@ function renderSettingsForm(domain) {
|
|
|
|
|
|
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>
|
|
|
- `;
|
|
|
+ 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>
|
|
|
+ <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>
|
|
|
`;
|
|
|
@@ -425,60 +507,22 @@ function renderSendForm(domain) {
|
|
|
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>
|
|
|
- `;
|
|
|
+ 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>';
|
|
|
- }
|
|
|
+ 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 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>
|
|
|
`;
|
|
|
}
|
|
|
@@ -489,56 +533,41 @@ function renderOptionalRecords(records) {
|
|
|
}
|
|
|
|
|
|
function renderWarnings(warnings) {
|
|
|
- return `
|
|
|
- <ul class="warning-list">
|
|
|
- ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
|
|
|
- </ul>
|
|
|
- `;
|
|
|
+ return `<ul class="warning-list">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`;
|
|
|
}
|
|
|
|
|
|
function apiExample(domain) {
|
|
|
+ const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
|
|
|
return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
|
|
|
- -H 'Authorization: Bearer <API_TOKEN>' \\
|
|
|
+ -H 'Authorization: Bearer ${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."
|
|
|
+ "text": "Signed with DKIM and queued by MailHub."
|
|
|
}'`;
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
- });
|
|
|
+ await mutate(async () => {
|
|
|
+ const data = Object.fromEntries(new FormData(event.target).entries());
|
|
|
+ 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)
|
|
|
- });
|
|
|
+ await mutate(async () => {
|
|
|
+ const data = Object.fromEntries(new FormData(event.target).entries());
|
|
|
+ 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;
|
|
|
@@ -548,42 +577,68 @@ async function saveSmtpCredential(event) {
|
|
|
renderDefaults();
|
|
|
render();
|
|
|
toast('SMTP 凭据已保存');
|
|
|
- } catch (error) {
|
|
|
- toast(error.message);
|
|
|
- } finally {
|
|
|
- setBusy(false);
|
|
|
- }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function saveDnsCredential(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ await mutate(async () => {
|
|
|
+ const data = Object.fromEntries(new FormData(event.target).entries());
|
|
|
+ const result = await api('/api/dns-credentials', { method: 'POST', body: JSON.stringify(data) });
|
|
|
+ state.dnsCredentials.unshift(result.credential);
|
|
|
+ event.target.reset();
|
|
|
+ renderDefaults();
|
|
|
+ render();
|
|
|
+ toast('DNS 凭据已保存');
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function createApiToken(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ await mutate(async () => {
|
|
|
+ const data = Object.fromEntries(new FormData(event.target).entries());
|
|
|
+ const result = await api('/api/api-tokens', { method: 'POST', body: JSON.stringify(data) });
|
|
|
+ state.apiTokens.unshift(result.token);
|
|
|
+ event.target.reset();
|
|
|
+ renderDefaults();
|
|
|
+ render();
|
|
|
+ await navigator.clipboard.writeText(result.token.token);
|
|
|
+ toast('Token 已生成并复制,请立即保存');
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
function generateSmtpPassword() {
|
|
|
const bytes = new Uint8Array(24);
|
|
|
crypto.getRandomValues(bytes);
|
|
|
- const password = btoa(String.fromCharCode(...bytes))
|
|
|
- .replace(/[+/=]/g, '')
|
|
|
- .slice(0, 28);
|
|
|
+ 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) {
|
|
|
+async function handleGlobalClick(event) {
|
|
|
+ const copyButton = event.target.closest('[data-copy]');
|
|
|
+ if (copyButton) {
|
|
|
+ event.preventDefault();
|
|
|
+ const value = copyButton.dataset.copy || '';
|
|
|
+ if (!value) return;
|
|
|
+ await navigator.clipboard.writeText(value);
|
|
|
+ toast('已复制');
|
|
|
+ return;
|
|
|
+ }
|
|
|
const action = event.target.closest('[data-action]')?.dataset.action;
|
|
|
if (!action) return;
|
|
|
+ const id = Number(event.target.closest('[data-id]')?.dataset.id || 0);
|
|
|
if (action === 'check') return checkSelected();
|
|
|
+ if (action === 'apply-dns') return applyDnsSelected();
|
|
|
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('已复制');
|
|
|
+ if (action === 'test-dns') return testDnsCredential(id);
|
|
|
+ if (action === 'delete-dns') return deleteDnsCredential(id);
|
|
|
+ if (action === 'delete-token') return deleteApiToken(id);
|
|
|
+ if (action === 'toggle-user') return toggleUser(id, event.target.closest('[data-status]')?.dataset.status);
|
|
|
}
|
|
|
|
|
|
async function handleDetailSubmit(event) {
|
|
|
@@ -592,102 +647,130 @@ async function handleDetailSubmit(event) {
|
|
|
event.preventDefault();
|
|
|
if (form.dataset.form === 'settings') return saveSettings(form);
|
|
|
if (form.dataset.form === 'send') return sendTest(form);
|
|
|
+ if (form.dataset.form === 'admin-settings') return saveAdminSettings(form);
|
|
|
}
|
|
|
|
|
|
async function checkSelected() {
|
|
|
const id = state.selectedId;
|
|
|
if (!id) return;
|
|
|
- setBusy(true);
|
|
|
- try {
|
|
|
+ await mutate(async () => {
|
|
|
const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
|
|
|
replaceDomain(result.domain);
|
|
|
render();
|
|
|
- } catch (error) {
|
|
|
- toast(error.message);
|
|
|
- } finally {
|
|
|
- setBusy(false);
|
|
|
- }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function applyDnsSelected() {
|
|
|
+ const id = state.selectedId;
|
|
|
+ if (!id) return;
|
|
|
+ await mutate(async () => {
|
|
|
+ const result = await api(`/api/domains/${id}/apply-dns`, { method: 'POST' });
|
|
|
+ replaceDomain(result.domain);
|
|
|
+ render();
|
|
|
+ toast(result.apply?.ok ? 'DNS 配置完成' : 'DNS 配置部分失败');
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
- });
|
|
|
+ await mutate(async () => {
|
|
|
+ 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)
|
|
|
- });
|
|
|
+ await mutate(async () => {
|
|
|
+ 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);
|
|
|
- }
|
|
|
+ toast('已提交到发信队列');
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function saveAdminSettings(form) {
|
|
|
+ const data = Object.fromEntries(new FormData(form).entries());
|
|
|
+ await mutate(async () => {
|
|
|
+ const result = await api('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(data) });
|
|
|
+ state.settings = result.settings;
|
|
|
+ state.config = { ...state.config, ...result.settings };
|
|
|
+ renderDefaults();
|
|
|
+ render();
|
|
|
+ toast('系统设置已保存');
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
async function rotateDkim() {
|
|
|
const id = state.selectedId;
|
|
|
if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
|
|
|
- setBusy(true);
|
|
|
- try {
|
|
|
+ await mutate(async () => {
|
|
|
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 mutate(async () => {
|
|
|
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 testDnsCredential(id) {
|
|
|
+ await mutate(async () => {
|
|
|
+ const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
|
|
|
+ toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败');
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteDnsCredential(id) {
|
|
|
+ if (!confirm('删除该 DNS API 凭据?')) return;
|
|
|
+ await mutate(async () => {
|
|
|
+ await api(`/api/dns-credentials/${id}`, { method: 'DELETE' });
|
|
|
+ state.dnsCredentials = state.dnsCredentials.filter((item) => item.id !== id);
|
|
|
+ state.domains = state.domains.map((domain) => domain.dnsCredentialId === id ? { ...domain, dnsCredentialId: null } : domain);
|
|
|
+ renderDefaults();
|
|
|
+ render();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteApiToken(id) {
|
|
|
+ if (!confirm('删除该 API Token?')) return;
|
|
|
+ await mutate(async () => {
|
|
|
+ await api(`/api/api-tokens/${id}`, { method: 'DELETE' });
|
|
|
+ state.apiTokens = state.apiTokens.filter((item) => item.id !== id);
|
|
|
+ renderDefaults();
|
|
|
+ render();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleUser(id, status) {
|
|
|
+ const next = status === 'active' ? 'disabled' : 'active';
|
|
|
+ await mutate(async () => {
|
|
|
+ const result = await api(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify({ status: next }) });
|
|
|
+ state.users = state.users.map((user) => user.id === id ? result.user : user);
|
|
|
+ renderDefaults();
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
+ const text = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
toast('已复制全部 DNS 记录');
|
|
|
}
|
|
|
@@ -701,13 +784,21 @@ async function logout() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+async function mutate(fn) {
|
|
|
+ setBusy(true);
|
|
|
+ try {
|
|
|
+ await fn();
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message);
|
|
|
+ } finally {
|
|
|
+ setBusy(false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function api(path, options = {}) {
|
|
|
const response = await fetch(path, {
|
|
|
...options,
|
|
|
- headers: {
|
|
|
- 'Content-Type': 'application/json',
|
|
|
- ...(options.headers || {})
|
|
|
- }
|
|
|
+ headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
|
});
|
|
|
const text = await response.text();
|
|
|
const payload = text ? JSON.parse(text) : {};
|
|
|
@@ -737,6 +828,10 @@ function badge(meta) {
|
|
|
return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
|
|
|
}
|
|
|
|
|
|
+function providerLabel(provider) {
|
|
|
+ return { cloudflare: 'Cloudflare', aliyun: '阿里云 DNS', dnspod: '腾讯云 DNSPod' }[provider] || provider;
|
|
|
+}
|
|
|
+
|
|
|
function setBusy(value) {
|
|
|
state.busy = value;
|
|
|
document.body.classList.toggle('busy', value);
|
|
|
@@ -757,12 +852,7 @@ function toast(message) {
|
|
|
|
|
|
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));
|
|
|
+ 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) {
|