|
@@ -6,14 +6,26 @@ const state = {
|
|
|
apiTokens: [],
|
|
apiTokens: [],
|
|
|
domains: [],
|
|
domains: [],
|
|
|
events: [],
|
|
events: [],
|
|
|
|
|
+ analytics: null,
|
|
|
users: [],
|
|
users: [],
|
|
|
settings: null,
|
|
settings: null,
|
|
|
selectedId: null,
|
|
selectedId: null,
|
|
|
- view: 'domains',
|
|
|
|
|
|
|
+ view: 'dashboard',
|
|
|
|
|
+ dnsTestResults: {},
|
|
|
busy: false
|
|
busy: false
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const viewMeta = {
|
|
const viewMeta = {
|
|
|
|
|
+ dashboard: {
|
|
|
|
|
+ eyebrow: 'Command Center',
|
|
|
|
|
+ title: '发信控制台',
|
|
|
|
|
+ subtitle: '总览域名健康、发信趋势和关键配置状态。'
|
|
|
|
|
+ },
|
|
|
|
|
+ analytics: {
|
|
|
|
|
+ eyebrow: 'Analytics',
|
|
|
|
|
+ title: '统计分析',
|
|
|
|
|
+ subtitle: '按时间、域名和状态分析发信表现。'
|
|
|
|
|
+ },
|
|
|
domains: {
|
|
domains: {
|
|
|
eyebrow: 'Domains',
|
|
eyebrow: 'Domains',
|
|
|
title: '发信域名',
|
|
title: '发信域名',
|
|
@@ -46,6 +58,8 @@ const els = {
|
|
|
viewEyebrow: document.querySelector('#viewEyebrow'),
|
|
viewEyebrow: document.querySelector('#viewEyebrow'),
|
|
|
viewTitle: document.querySelector('#viewTitle'),
|
|
viewTitle: document.querySelector('#viewTitle'),
|
|
|
securityNotice: document.querySelector('#securityNotice'),
|
|
securityNotice: document.querySelector('#securityNotice'),
|
|
|
|
|
+ dashboardPanel: document.querySelector('#dashboardPanel'),
|
|
|
|
|
+ analyticsPanel: document.querySelector('#analyticsPanel'),
|
|
|
accountBox: document.querySelector('#accountBox'),
|
|
accountBox: document.querySelector('#accountBox'),
|
|
|
userRole: document.querySelector('#userRole'),
|
|
userRole: document.querySelector('#userRole'),
|
|
|
navButtons: document.querySelectorAll('.primary-nav [data-view]'),
|
|
navButtons: document.querySelectorAll('.primary-nav [data-view]'),
|
|
@@ -74,6 +88,7 @@ const els = {
|
|
|
apiTokenForm: document.querySelector('#apiTokenForm'),
|
|
apiTokenForm: document.querySelector('#apiTokenForm'),
|
|
|
apiTokenList: document.querySelector('#apiTokenList'),
|
|
apiTokenList: document.querySelector('#apiTokenList'),
|
|
|
apiTokenCount: document.querySelector('#apiTokenCount'),
|
|
apiTokenCount: document.querySelector('#apiTokenCount'),
|
|
|
|
|
+ analyticsEventCount: document.querySelector('#analyticsEventCount'),
|
|
|
defaultSenderHost: document.querySelector('#defaultSenderHost'),
|
|
defaultSenderHost: document.querySelector('#defaultSenderHost'),
|
|
|
defaultSendingIp: document.querySelector('#defaultSendingIp'),
|
|
defaultSendingIp: document.querySelector('#defaultSendingIp'),
|
|
|
defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
|
|
defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
|
|
@@ -126,14 +141,16 @@ async function refreshAll() {
|
|
|
api('/api/config'),
|
|
api('/api/config'),
|
|
|
api('/api/domains'),
|
|
api('/api/domains'),
|
|
|
api('/api/events'),
|
|
api('/api/events'),
|
|
|
|
|
+ api('/api/analytics?days=30'),
|
|
|
api('/api/smtp-credential'),
|
|
api('/api/smtp-credential'),
|
|
|
api('/api/dns-credentials'),
|
|
api('/api/dns-credentials'),
|
|
|
api('/api/api-tokens')
|
|
api('/api/api-tokens')
|
|
|
];
|
|
];
|
|
|
- const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
|
|
|
|
|
|
|
+ const [config, domains, events, analytics, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
|
|
|
state.config = config;
|
|
state.config = config;
|
|
|
state.domains = domains.domains || [];
|
|
state.domains = domains.domains || [];
|
|
|
state.events = events.events || [];
|
|
state.events = events.events || [];
|
|
|
|
|
+ state.analytics = analytics.analytics || null;
|
|
|
state.smtpCredential = smtpCredential.credential || null;
|
|
state.smtpCredential = smtpCredential.credential || null;
|
|
|
state.dnsCredentials = dnsCredentials.credentials || [];
|
|
state.dnsCredentials = dnsCredentials.credentials || [];
|
|
|
state.apiTokens = apiTokens.tokens || [];
|
|
state.apiTokens = apiTokens.tokens || [];
|
|
@@ -182,6 +199,8 @@ function renderDefaults() {
|
|
|
</div>
|
|
</div>
|
|
|
`;
|
|
`;
|
|
|
renderDnsOptions();
|
|
renderDnsOptions();
|
|
|
|
|
+ renderDashboard();
|
|
|
|
|
+ renderAnalyticsPanel();
|
|
|
renderDomainReadiness();
|
|
renderDomainReadiness();
|
|
|
renderSmtpCredentialCopy();
|
|
renderSmtpCredentialCopy();
|
|
|
renderDnsCredentials();
|
|
renderDnsCredentials();
|
|
@@ -195,7 +214,10 @@ function render() {
|
|
|
els.domainCount.textContent = String(state.domains.length);
|
|
els.domainCount.textContent = String(state.domains.length);
|
|
|
els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
|
|
els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
|
|
|
els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
|
|
|
+ els.analyticsEventCount.textContent = String(state.analytics?.summary?.total || 0);
|
|
|
renderViewMeta();
|
|
renderViewMeta();
|
|
|
|
|
+ renderDashboard();
|
|
|
|
|
+ renderAnalyticsPanel();
|
|
|
renderDomainList();
|
|
renderDomainList();
|
|
|
renderDetail();
|
|
renderDetail();
|
|
|
}
|
|
}
|
|
@@ -230,6 +252,246 @@ function renderSecurityNotice() {
|
|
|
: '';
|
|
: '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function renderDashboard() {
|
|
|
|
|
+ if (!els.dashboardPanel) return;
|
|
|
|
|
+ const analytics = state.analytics || {};
|
|
|
|
|
+ const summary = analytics.summary || {};
|
|
|
|
|
+ const totalDomains = state.domains.length;
|
|
|
|
|
+ const verifiedDomains = state.domains.filter((domain) => domain.status?.verified).length;
|
|
|
|
|
+ const dnsReady = state.dnsCredentials.length > 0;
|
|
|
|
|
+ const smtpReady = Boolean(state.smtpCredential?.passwordSet);
|
|
|
|
|
+ const tokenReady = state.apiTokens.length > 0;
|
|
|
|
|
+ const domainScore = totalDomains ? (verifiedDomains / totalDomains) * 42 : 0;
|
|
|
|
|
+ const score = Math.min(100, Math.round(domainScore + (dnsReady ? 18 : 0) + (smtpReady ? 18 : 0) + (tokenReady ? 8 : 0) + ((summary.successRate || 0) * 0.14)));
|
|
|
|
|
+ const risks = dashboardRisks(summary, { totalDomains, verifiedDomains, dnsReady, smtpReady, tokenReady });
|
|
|
|
|
+ els.dashboardPanel.innerHTML = `
|
|
|
|
|
+ <section class="console-hero">
|
|
|
|
|
+ <div class="console-copy">
|
|
|
|
|
+ <span class="eyebrow">MailHub Command Center</span>
|
|
|
|
|
+ <h2>多租户发信运营后台</h2>
|
|
|
|
|
+ <p>${escapeHtml(state.config?.mailHostname || 'mail host')} · ${escapeHtml(state.config?.sendingIp || '未设置发信 IP')} · ${escapeHtml(state.me?.email || '')}</p>
|
|
|
|
|
+ <div class="hero-actions">
|
|
|
|
|
+ <button class="btn btn-primary" data-view="domains" type="button"><i class="ti ti-world-plus"></i> 添加域名</button>
|
|
|
|
|
+ <button class="btn btn-outline-secondary" data-view="analytics" type="button"><i class="ti ti-chart-histogram"></i> 查看分析</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="health-orbit" style="--score: ${score}%">
|
|
|
|
|
+ <span>健康评分</span>
|
|
|
|
|
+ <strong>${score}</strong>
|
|
|
|
|
+ <small>${score >= 90 ? '运行良好' : score >= 70 ? '仍有优化项' : '需要配置'}</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="ops-grid">
|
|
|
|
|
+ ${kpiTile('今日发送', summary.today || 0, `${summary.last7Days || 0} / 近 7 日`, 'ti-send', 'cyan')}
|
|
|
|
|
+ ${kpiTile('成功率', `${summary.successRate || 0}%`, `${summary.queued || 0} 成功 / ${summary.failed || 0} 失败`, 'ti-shield-check', 'green')}
|
|
|
|
|
+ ${kpiTile('域名健康', `${verifiedDomains}/${totalDomains}`, '已验证发信域名', 'ti-world-check', 'blue')}
|
|
|
|
|
+ ${kpiTile('收件人触达', summary.recipients || 0, '统计窗口内收件人数', 'ti-users', 'amber')}
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="dashboard-layout">
|
|
|
|
|
+ <div class="data-panel trend-panel">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="eyebrow">Traffic</span>
|
|
|
|
|
+ <h3>近 14 日发信趋势</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="badge bg-cyan-lt">${summary.total || 0} events</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderDailyBars((analytics.byDay || []).slice(-14))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="data-panel">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="eyebrow">Readiness</span>
|
|
|
|
|
+ <h3>上线检查</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="readiness-list">
|
|
|
|
|
+ ${readinessItem('DNS API', dnsReady, dnsReady ? `${state.dnsCredentials.length} 组可用` : '需要添加 DNS API')}
|
|
|
|
|
+ ${readinessItem('SMTP 凭据', smtpReady, smtpReady ? state.smtpCredential.username : '需要设置平台凭据')}
|
|
|
|
|
+ ${readinessItem('发信域名', Boolean(totalDomains), totalDomains ? `${verifiedDomains} 个已验证` : '需要添加域名')}
|
|
|
|
|
+ ${readinessItem('API Token', tokenReady, tokenReady ? `${state.apiTokens.length} 个 Token` : '建议创建发送 Token')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="data-panel">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="eyebrow">Focus</span>
|
|
|
|
|
+ <h3>待处理事项</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderRiskList(risks)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAnalyticsPanel() {
|
|
|
|
|
+ if (!els.analyticsPanel) return;
|
|
|
|
|
+ const analytics = state.analytics || {};
|
|
|
|
|
+ const summary = analytics.summary || {};
|
|
|
|
|
+ const statusTotal = Math.max(1, summary.total || 0);
|
|
|
|
|
+ const queuedRatio = Math.round(((summary.queued || 0) / statusTotal) * 100);
|
|
|
|
|
+ els.analyticsPanel.innerHTML = `
|
|
|
|
|
+ <section class="analysis-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="eyebrow">Analytics Window</span>
|
|
|
|
|
+ <h2>${analytics.windowDays || 30} 天发信分析</h2>
|
|
|
|
|
+ <p>${escapeHtml(state.me?.username || '')} 的独立数据视图,按用户隔离统计。</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="analysis-summary">
|
|
|
|
|
+ ${miniMetric('总发送', summary.total || 0)}
|
|
|
|
|
+ ${miniMetric('今日', summary.today || 0)}
|
|
|
|
|
+ ${miniMetric('成功率', `${summary.successRate || 0}%`)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="analysis-grid">
|
|
|
|
|
+ <div class="data-panel analysis-wide">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div><span class="eyebrow">Daily Volume</span><h3>每日发送量</h3></div>
|
|
|
|
|
+ <span class="badge bg-blue-lt">${summary.recipients || 0} recipients</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderDailyBars(analytics.byDay || [])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="data-panel">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div><span class="eyebrow">Status</span><h3>状态分布</h3></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-donut" style="--queued: ${queuedRatio}%">
|
|
|
|
|
+ <strong>${queuedRatio}%</strong>
|
|
|
|
|
+ <span>queued</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="status-legend">
|
|
|
|
|
+ ${(analytics.byStatus || []).map((item) => `<div><span>${escapeHtml(statusLabel(item.status))}</span><strong>${escapeHtml(item.total)}</strong></div>`).join('') || '<p class="text-secondary">暂无发送数据</p>'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="data-panel">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div><span class="eyebrow">Domains</span><h3>域名排行</h3></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderDomainLeaderboard(analytics.byDomain || [])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="data-panel">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div><span class="eyebrow">Hour Map</span><h3>24 小时热力</h3></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderHourlyHeatmap(analytics.hourly || [])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="data-panel analysis-wide">
|
|
|
|
|
+ <div class="panel-heading">
|
|
|
|
|
+ <div><span class="eyebrow">Failures</span><h3>最近失败</h3></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderFailureList(analytics.recentFailures || [])}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function dashboardRisks(summary, flags) {
|
|
|
|
|
+ const risks = [];
|
|
|
|
|
+ if (!flags.smtpReady) risks.push({ tone: 'failed', title: 'SMTP 凭据未配置', detail: '客户端无法完成 SMTP Submission 认证。' });
|
|
|
|
|
+ if (!flags.dnsReady) risks.push({ tone: 'warn', title: 'DNS API 未配置', detail: '域名需要手动复制 DNS 记录。' });
|
|
|
|
|
+ if (flags.totalDomains && flags.verifiedDomains < flags.totalDomains) risks.push({ tone: 'warn', title: '存在未验证域名', detail: `${flags.totalDomains - flags.verifiedDomains} 个域名仍需检查 DNS。` });
|
|
|
|
|
+ if ((summary.failed || 0) > 0) risks.push({ tone: 'failed', title: '近期有发送失败', detail: `${summary.failed} 条失败记录,建议查看失败原因。` });
|
|
|
|
|
+ if (!flags.tokenReady) risks.push({ tone: 'idle', title: '未创建 API Token', detail: '生产系统建议使用独立 Token 接入。' });
|
|
|
|
|
+ return risks;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRiskList(risks) {
|
|
|
|
|
+ if (!risks.length) return '<div class="empty-soft">暂无待处理事项</div>';
|
|
|
|
|
+ return `<div class="risk-list">${risks.map((risk) => `
|
|
|
|
|
+ <div class="risk-row ${risk.tone}">
|
|
|
|
|
+ ${badge({ className: risk.tone, label: risk.tone === 'failed' ? '重点' : risk.tone === 'warn' ? '注意' : '建议' })}
|
|
|
|
|
+ <div><strong>${escapeHtml(risk.title)}</strong><span>${escapeHtml(risk.detail)}</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('')}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function kpiTile(label, value, hint, icon, tone) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <article class="kpi-tile ${tone}">
|
|
|
|
|
+ <i class="ti ${icon}" aria-hidden="true"></i>
|
|
|
|
|
+ <span>${escapeHtml(label)}</span>
|
|
|
|
|
+ <strong>${escapeHtml(value)}</strong>
|
|
|
|
|
+ <small>${escapeHtml(hint || '')}</small>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function miniMetric(label, value) {
|
|
|
|
|
+ return `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function readinessItem(label, ready, detail) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="readiness-item ${ready ? 'ok' : 'warn'}">
|
|
|
|
|
+ <i class="ti ${ready ? 'ti-circle-check' : 'ti-alert-triangle'}" aria-hidden="true"></i>
|
|
|
|
|
+ <div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderDailyBars(days) {
|
|
|
|
|
+ if (!days.length || !days.some((day) => day.total)) return '<div class="empty-soft">暂无发送数据</div>';
|
|
|
|
|
+ const max = Math.max(...days.map((day) => day.total), 1);
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="bar-chart">
|
|
|
|
|
+ ${days.map((day) => {
|
|
|
|
|
+ const totalHeight = Math.max(4, Math.round((day.total / max) * 100));
|
|
|
|
|
+ const queuedHeight = day.total ? Math.round((day.queued / day.total) * totalHeight) : 0;
|
|
|
|
|
+ const failedHeight = Math.max(0, totalHeight - queuedHeight);
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="bar-column" title="${escapeAttr(day.day)} · ${escapeAttr(day.total)}">
|
|
|
|
|
+ <div class="bar-track">
|
|
|
|
|
+ <span class="bar-failed" style="height:${failedHeight}%"></span>
|
|
|
|
|
+ <span class="bar-queued" style="height:${queuedHeight}%"></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <small>${escapeHtml(day.day.slice(5))}</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }).join('')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderDomainLeaderboard(rows) {
|
|
|
|
|
+ if (!rows.length) return '<div class="empty-soft">暂无域名发送数据</div>';
|
|
|
|
|
+ const max = Math.max(...rows.map((row) => row.total), 1);
|
|
|
|
|
+ return `<div class="leaderboard">${rows.map((row) => `
|
|
|
|
|
+ <div class="leader-row">
|
|
|
|
|
+ <div><strong>${escapeHtml(row.domain)}</strong><span>${escapeHtml(row.queued)} 成功 / ${escapeHtml(row.failed)} 失败</span></div>
|
|
|
|
|
+ <div class="leader-meter"><span style="width:${Math.round((row.total / max) * 100)}%"></span></div>
|
|
|
|
|
+ <b>${escapeHtml(row.total)}</b>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('')}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderHourlyHeatmap(rows) {
|
|
|
|
|
+ const max = Math.max(...rows.map((row) => row.total), 1);
|
|
|
|
|
+ return `<div class="hour-grid">${rows.map((row) => {
|
|
|
|
|
+ const level = row.total ? Math.max(1, Math.ceil((row.total / max) * 4)) : 0;
|
|
|
|
|
+ return `<span class="hour-cell level-${level}" title="${row.hour}:00 · ${row.total}">${String(row.hour).padStart(2, '0')}</span>`;
|
|
|
|
|
+ }).join('')}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderFailureList(rows) {
|
|
|
|
|
+ if (!rows.length) return '<div class="empty-soft">暂无失败记录</div>';
|
|
|
|
|
+ return `<div class="failure-list">${rows.map((row) => `
|
|
|
|
|
+ <div class="failure-row">
|
|
|
|
|
+ <div><strong>${escapeHtml(row.subject || '(no subject)')}</strong><span>${escapeHtml(row.sender)} · ${escapeHtml(formatDate(row.createdAt))}</span></div>
|
|
|
|
|
+ <code>${escapeHtml(row.detail || '未返回错误详情')}</code>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('')}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function renderDomainReadiness() {
|
|
function renderDomainReadiness() {
|
|
|
const verified = state.domains.filter((domain) => domain.status?.verified).length;
|
|
const verified = state.domains.filter((domain) => domain.status?.verified).length;
|
|
|
const latestEvent = state.events[0];
|
|
const latestEvent = state.events[0];
|
|
@@ -334,6 +596,7 @@ function renderDnsCredentials() {
|
|
|
${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
|
|
${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
|
|
|
</div>
|
|
</div>
|
|
|
<span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
|
|
<span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
|
|
|
|
|
+ ${renderDnsTestResult(credential.id)}
|
|
|
</div>
|
|
</div>
|
|
|
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
|
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
|
|
<button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
|
|
<button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
|
|
@@ -345,6 +608,17 @@ function renderDnsCredentials() {
|
|
|
`).join('');
|
|
`).join('');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function renderDnsTestResult(id) {
|
|
|
|
|
+ const result = state.dnsTestResults[id];
|
|
|
|
|
+ if (!result) return '';
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="dns-test-result ${result.ok ? 'ok' : 'failed'}">
|
|
|
|
|
+ <i class="ti ${result.ok ? 'ti-plug-connected' : 'ti-plug-connected-x'}" aria-hidden="true"></i>
|
|
|
|
|
+ <span>${escapeHtml(result.message)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function renderApiTokens() {
|
|
function renderApiTokens() {
|
|
|
els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
|
if (!state.apiTokens.length) {
|
|
if (!state.apiTokens.length) {
|
|
@@ -960,8 +1234,12 @@ async function sendTest(form) {
|
|
|
const data = Object.fromEntries(new FormData(form).entries());
|
|
const data = Object.fromEntries(new FormData(form).entries());
|
|
|
await mutate(async () => {
|
|
await mutate(async () => {
|
|
|
await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
|
|
await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
- const events = await api('/api/events');
|
|
|
|
|
|
|
+ const [events, analytics] = await Promise.all([
|
|
|
|
|
+ api('/api/events'),
|
|
|
|
|
+ api('/api/analytics?days=30')
|
|
|
|
|
+ ]);
|
|
|
state.events = events.events || [];
|
|
state.events = events.events || [];
|
|
|
|
|
+ state.analytics = analytics.analytics || state.analytics;
|
|
|
renderDefaults();
|
|
renderDefaults();
|
|
|
render();
|
|
render();
|
|
|
toast('已提交到发信队列');
|
|
toast('已提交到发信队列');
|
|
@@ -1007,7 +1285,12 @@ async function deleteSelected() {
|
|
|
async function testDnsCredential(id) {
|
|
async function testDnsCredential(id) {
|
|
|
await mutate(async () => {
|
|
await mutate(async () => {
|
|
|
const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
|
|
const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
|
|
|
- toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败');
|
|
|
|
|
|
|
+ state.dnsTestResults[id] = {
|
|
|
|
|
+ ok: result.ok,
|
|
|
|
|
+ message: result.ok ? `连接成功:${result.detail || providerLabel(result.provider)}` : (result.error || 'DNS API 连接失败')
|
|
|
|
|
+ };
|
|
|
|
|
+ renderDnsCredentials();
|
|
|
|
|
+ toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败', result.ok ? 'info' : 'error');
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1093,6 +1376,13 @@ function replaceDomain(domain) {
|
|
|
state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
|
|
state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function statusLabel(status) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ queued: '成功入队',
|
|
|
|
|
+ failed: '发送失败'
|
|
|
|
|
+ }[status] || status || 'unknown';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function statusMeta(target) {
|
|
function statusMeta(target) {
|
|
|
if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
|
|
if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
|
|
|
if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
|
|
if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
|