ソースを参照

feat: redesign control panel with admin ui kit

Codex 1 ヶ月 前
コミット
6a3a99429a
3 ファイル変更996 行追加872 行削除
  1. 439 159
      public/app.js
  2. 326 156
      public/index.html
  3. 231 557
      public/styles.css

+ 439 - 159
public/app.js

@@ -9,16 +9,51 @@ const state = {
   users: [],
   settings: null,
   selectedId: null,
+  view: 'domains',
   busy: false
 };
 
+const viewMeta = {
+  domains: {
+    eyebrow: 'Domains',
+    title: '发信域名',
+    subtitle: '管理发信域名、DNS 检查和测试发送。'
+  },
+  dns: {
+    eyebrow: 'DNS API',
+    title: 'DNS API 凭据',
+    subtitle: '按服务商保存多组 DNS 凭据,再绑定到具体域名。'
+  },
+  smtp: {
+    eyebrow: 'SMTP',
+    title: 'SMTP 凭据',
+    subtitle: '生成可复制的 SMTP 用户名和密码。'
+  },
+  tokens: {
+    eyebrow: 'API Tokens',
+    title: '发送 API Token',
+    subtitle: '为不同系统创建独立发信 Token。'
+  },
+  admin: {
+    eyebrow: 'Admin',
+    title: '系统设置',
+    subtitle: '管理员设置默认发信参数和用户状态。'
+  }
+};
+
 const els = {
   runtimeLine: document.querySelector('#runtimeLine'),
+  viewEyebrow: document.querySelector('#viewEyebrow'),
+  viewTitle: document.querySelector('#viewTitle'),
   securityNotice: document.querySelector('#securityNotice'),
   accountBox: document.querySelector('#accountBox'),
   userRole: document.querySelector('#userRole'),
+  navButtons: document.querySelectorAll('.primary-nav [data-view]'),
+  viewPanels: document.querySelectorAll('[data-view-panel]'),
+  adminNavButton: document.querySelector('#adminNavButton'),
   addDomainForm: document.querySelector('#addDomainForm'),
   domainDnsCredential: document.querySelector('#domainDnsCredential'),
+  domainReadiness: document.querySelector('#domainReadiness'),
   smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
   smtpCredentialState: document.querySelector('#smtpCredentialState'),
   smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
@@ -26,8 +61,16 @@ const els = {
   smtpPassword: document.querySelector('#smtpPassword'),
   generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
   dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
+  dnsCredentialFormTitle: document.querySelector('#dnsCredentialFormTitle'),
+  dnsCredentialFormHint: document.querySelector('#dnsCredentialFormHint'),
+  dnsCredentialIdField: document.querySelector('#dnsCredentialIdField'),
+  dnsCredentialName: document.querySelector('#dnsCredentialName'),
+  dnsDefaultTtl: document.querySelector('#dnsDefaultTtl'),
+  dnsProvider: document.querySelector('#dnsProvider'),
+  dnsZoneName: document.querySelector('#dnsZoneName'),
   dnsCredentialList: document.querySelector('#dnsCredentialList'),
   dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
+  resetDnsCredentialForm: document.querySelector('#resetDnsCredentialForm'),
   apiTokenForm: document.querySelector('#apiTokenForm'),
   apiTokenList: document.querySelector('#apiTokenList'),
   apiTokenCount: document.querySelector('#apiTokenCount'),
@@ -39,7 +82,9 @@ const els = {
   detailPanel: document.querySelector('#detailPanel'),
   adminPanel: document.querySelector('#adminPanel'),
   refreshButton: document.querySelector('#refreshButton'),
-  logoutButton: document.querySelector('#logoutButton')
+  refreshButtonMobile: document.querySelector('#refreshButtonMobile'),
+  logoutButton: document.querySelector('#logoutButton'),
+  toastHost: document.querySelector('#toastHost')
 };
 
 init();
@@ -51,17 +96,21 @@ async function init() {
 
 function bindEvents() {
   els.refreshButton.addEventListener('click', refreshAll);
+  els.refreshButtonMobile?.addEventListener('click', refreshAll);
   els.logoutButton.addEventListener('click', logout);
   document.addEventListener('click', handleGlobalClick);
   els.addDomainForm.addEventListener('submit', addDomain);
   els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
   els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
   els.dnsCredentialForm.addEventListener('submit', saveDnsCredential);
+  els.dnsProvider.addEventListener('change', toggleDnsProviderFields);
+  els.resetDnsCredentialForm.addEventListener('click', resetDnsCredentialForm);
   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);
+    setView('domains');
     render();
   });
   els.detailPanel.addEventListener('submit', handleDetailSubmit);
@@ -103,7 +152,7 @@ async function refreshAll() {
     renderDefaults();
     render();
   } catch (error) {
-    toast(error.message);
+    toast(error.message, 'error');
   } finally {
     setBusy(false);
   }
@@ -111,42 +160,127 @@ async function refreshAll() {
 
 function renderDefaults() {
   if (!state.config) return;
-  els.runtimeLine.textContent = `${state.config.mailHostname} / ${state.config.sendingIp || '未设置发信 IP'}`;
+  const ip = state.config.sendingIp || '未设置发信 IP';
+  els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${viewMeta[state.view]?.subtitle || ''}`;
   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'}`;
   els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户';
-  els.userRole.className = `badge ${state.me?.role === 'admin' ? 'ok' : 'idle'}`;
+  els.userRole.className = `badge ${state.me?.role === 'admin' ? 'bg-green-lt' : 'bg-secondary-lt'}`;
+  els.adminNavButton.classList.toggle('hidden', state.me?.role !== 'admin');
+  if (state.me?.role !== 'admin' && state.view === 'admin') setView('domains');
   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>
+    <div class="list-group-item px-0 py-2 bg-transparent border-0">
+      <div class="text-secondary small">Username</div>
+      <code>${escapeHtml(state.me?.username || '')}</code>
+    </div>
+    <div class="list-group-item px-0 py-2 bg-transparent border-0">
+      <div class="text-secondary small">Email</div>
+      <code>${escapeHtml(state.me?.email || '')}</code>
+    </div>
   `;
   renderDnsOptions();
+  renderDomainReadiness();
   renderSmtpCredentialCopy();
   renderDnsCredentials();
   renderApiTokens();
   renderAdminPanel();
-  els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
-  els.securityNotice.textContent = state.config.usingDefaultAdminPassword
-    ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
-    : '';
+  renderSecurityNotice();
+  toggleDnsProviderFields();
 }
 
 function render() {
   els.domainCount.textContent = String(state.domains.length);
+  els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
+  els.apiTokenCount.textContent = String(state.apiTokens.length);
+  renderViewMeta();
   renderDomainList();
   renderDetail();
 }
 
+function renderViewMeta() {
+  const meta = viewMeta[state.view] || viewMeta.domains;
+  els.viewEyebrow.textContent = meta.eyebrow;
+  els.viewTitle.textContent = meta.title;
+  if (state.config) {
+    const ip = state.config.sendingIp || '未设置发信 IP';
+    els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${meta.subtitle}`;
+  }
+  for (const button of els.navButtons) {
+    button.classList.toggle('active', button.dataset.view === state.view);
+  }
+  for (const panel of els.viewPanels) {
+    panel.classList.toggle('active', panel.dataset.viewPanel === state.view);
+  }
+}
+
+function setView(view) {
+  if (!viewMeta[view]) return;
+  if (view === 'admin' && state.me?.role !== 'admin') return;
+  state.view = view;
+  renderViewMeta();
+}
+
+function renderSecurityNotice() {
+  els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
+  els.securityNotice.textContent = state.config.usingDefaultAdminPassword
+    ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
+    : '';
+}
+
+function renderDomainReadiness() {
+  const verified = state.domains.filter((domain) => domain.status?.verified).length;
+  const latestEvent = state.events[0];
+  els.domainReadiness.innerHTML = `
+    <div class="card-header">
+      <div>
+        <h3 class="card-title">运行概览</h3>
+        <p class="card-subtitle">关键配置集中在这里,缺哪一项就先补哪一项。</p>
+      </div>
+    </div>
+    <div class="card-body">
+      <div class="row row-cards">
+        ${metricCard('域名', state.domains.length, `${verified} 个已通过`)}
+        ${metricCard('DNS API', state.dnsCredentials.length, state.dnsCredentials.length ? '可一键写入' : '尚未配置')}
+        ${metricCard('SMTP', state.smtpCredential?.passwordSet ? 'Ready' : 'Missing', state.smtpCredential?.username || '未设置凭据')}
+        ${metricCard('最近发送', latestEvent ? formatDate(latestEvent.createdAt) : '暂无', latestEvent?.status || '无记录')}
+      </div>
+      <div class="d-flex flex-wrap gap-2 mt-3">
+        ${!state.dnsCredentials.length ? '<button class="btn btn-outline-secondary" data-view="dns" type="button">新增 DNS API</button>' : ''}
+        ${!state.smtpCredential?.passwordSet ? '<button class="btn btn-outline-secondary" data-view="smtp" type="button">设置 SMTP</button>' : ''}
+        <button class="btn btn-outline-secondary" data-action="check" type="button" ${state.selectedId ? '' : 'disabled'}>检查当前域名</button>
+      </div>
+    </div>
+  `;
+}
+
+function metricCard(label, value, hint) {
+  return `
+    <div class="col-sm-6">
+      <div class="card card-sm metric-card">
+        <div class="card-body">
+          <div class="text-secondary small">${escapeHtml(label)}</div>
+          <div class="h2 m-0">${escapeHtml(value)}</div>
+          <div class="text-secondary small">${escapeHtml(hint || '')}</div>
+        </div>
+      </div>
+    </div>
+  `;
+}
+
 function renderDnsOptions(selectedId = '') {
+  const activeId = selectedId || els.domainDnsCredential.value || '';
+  if (!state.dnsCredentials.length) {
+    els.domainDnsCredential.innerHTML = '<option value="">未绑定,先手动配置或新增 DNS API</option>';
+    return;
+  }
   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 value="${credential.id}" ${String(activeId) === String(credential.id) ? 'selected' : ''}>
+        ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}
       </option>
     `)
   ].join('');
@@ -156,7 +290,12 @@ function renderDnsOptions(selectedId = '') {
 function renderSmtpCredentialCopy() {
   const credential = state.smtpCredential;
   if (!credential?.username && !credential?.passwordSet) {
-    els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
+    els.smtpCredentialCopy.innerHTML = `
+      <div class="empty">
+        <div class="empty-title">还没有 SMTP 凭据</div>
+        <p>保存用户名和密码后,这里会显示可复制内容。</p>
+      </div>
+    `;
     return;
   }
   const password = credential?.password || '';
@@ -164,13 +303,13 @@ 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">复制</button>
+      <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</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>'}
+        ? `<code>${escapeHtml(password)}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(password)}" type="button">复制</button>`
+        : '<p class="text-secondary">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
     </div>
   `;
 }
@@ -178,36 +317,52 @@ function renderSmtpCredentialCopy() {
 function renderDnsCredentials() {
   els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
   if (!state.dnsCredentials.length) {
-    els.dnsCredentialList.innerHTML = '<p class="muted">暂无 DNS API 凭据</p>';
+    els.dnsCredentialList.innerHTML = `
+      <div class="empty">
+        <div class="empty-title">暂无 DNS API 凭据</div>
+        <p>先在右侧新增一组凭据;添加域名时下拉框就会出现可选项。</p>
+      </div>
+    `;
     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>
+    <article class="card credential-row">
+      <div class="card-body">
+        <div>
+          <div class="d-flex align-items-center justify-content-between gap-2 mb-1">
+            <strong>${escapeHtml(credential.name)}</strong>
+            ${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
+          </div>
+          <span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
+        </div>
+        <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="test-dns" data-id="${credential.id}" type="button">测试</button>
+          <button class="btn btn-sm btn-outline-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
+        </div>
       </div>
-    </div>
+    </article>
   `).join('');
 }
 
 function renderApiTokens() {
   els.apiTokenCount.textContent = String(state.apiTokens.length);
   if (!state.apiTokens.length) {
-    els.apiTokenList.innerHTML = '<p class="muted">暂无发送 Token</p>';
+    els.apiTokenList.innerHTML = `
+      <div class="empty">
+        <div class="empty-title">暂无发送 Token</div>
+        <p>创建后请立即保存完整 Token,页面之后只显示前缀。</p>
+      </div>
+    `;
     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>
+        <span class="text-secondary">${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>
+      <button class="btn btn-sm btn-outline-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
     </div>
   `).join('');
 }
@@ -221,61 +376,97 @@ function renderAdminPanel() {
   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 class="card-header">
+      <div>
+        <h3 class="card-title">系统默认值</h3>
+        <p class="card-subtitle">这些值会作为新域名的默认 DNS 和发信参数。</p>
       </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 class="card-actions">
+        <span class="badge bg-blue-lt">${state.users.length} 用户</span>
       </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>
+    <div class="card-body">
+      <form class="compact-form" data-form="admin-settings">
+        <div class="mb-3">
+          <label class="form-label">APP Base URL</label>
+          <input class="form-control" name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}">
+          <div class="form-hint">API 示例和外部访问地址。</div>
+        </div>
+        <div class="row">
+          <div class="col-sm-6 mb-3">
+            <label class="form-label">发信主机</label>
+            <input class="form-control" name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}">
+            <div class="form-hint">建议使用 mail.example.com。</div>
+          </div>
+          <div class="col-sm-6 mb-3">
+            <label class="form-label">发信 IP</label>
+            <input class="form-control" name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}">
+            <div class="form-hint">服务器公网 IPv4。</div>
+          </div>
+        </div>
+        <div class="mb-3">
+          <label class="form-label">默认 SPF</label>
+          <textarea class="form-control" name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea>
+          <div class="form-hint">会合并到 v=spf1 中,不要重复写 v=spf1。</div>
+        </div>
+        <div class="row">
+          <div class="col-sm-6 mb-3">
+            <label class="form-label">DMARC</label>
+            <select class="form-select" name="dmarcPolicy">
+              ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
+            </select>
+          </div>
+          <div class="col-sm-6 mb-3">
+            <label class="form-label">验证后发信</label>
+            <select class="form-select" name="sendRequiresVerified">
+              <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
+              <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
+            </select>
+          </div>
+        </div>
+        <div class="mb-3">
+          <label class="form-label">DMARC rua</label>
+          <input class="form-control" name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
+        </div>
+        <button class="btn btn-primary" type="submit">保存系统设置</button>
+      </form>
+    </div>
+    <div class="card-header border-top">
+      <div>
+        <h3 class="card-title">用户状态</h3>
+        <p class="card-subtitle">禁用用户后,该用户的控制台、API Token 和 SMTP 认证都会失效。</p>
+      </div>
+    </div>
+    <div class="card-body">
+      <div class="mini-list user-mini-list">
+      ${state.users.slice(0, 12).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>
+            <span class="text-secondary">${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">
+          <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">
             ${user.status === 'active' ? '禁用' : '启用'}
           </button>
         </div>
       `).join('')}
+      </div>
     </div>
   `;
 }
 
 function renderDomainList() {
   if (!state.domains.length) {
-    els.domainList.innerHTML = '<p class="muted">暂无域名</p>';
+    els.domainList.innerHTML = '<div class="empty-mini">暂无域名</div>';
     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>
+        <span>${escapeHtml(domain.domain)}</span>
+        ${badge(status)}
+        <small>${escapeHtml(domain.selector)}._domainkey</small>
       </button>
     `;
   }).join('');
@@ -296,67 +487,83 @@ function renderDetail() {
   const records = guide.records || [];
   const warnings = guide.warnings || [];
   const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
+  const applyDisabled = domain.dnsCredentialId ? '' : 'disabled';
+  const applyTitle = domain.dnsCredentialId ? '一键配置 DNS' : '请先在域名设置里绑定 DNS API 凭据';
   els.detailPanel.innerHTML = `
-    <div class="detail-header">
-      <div class="detail-title">
-        <h2>${escapeHtml(domain.domain)}</h2>
+    <div class="card-header detail-header">
+      <div>
+        <h3 class="card-title">${escapeHtml(domain.domain)}</h3>
         <div class="status-strip">
           ${badge(statusMeta(guide))}
-          <span class="badge idle">最近检查 ${escapeHtml(checkedAt)}</span>
-          <span class="badge idle">Selector ${escapeHtml(domain.selector)}</span>
+          <span class="badge bg-secondary-lt">最近检查 ${escapeHtml(checkedAt)}</span>
+          <span class="badge bg-secondary-lt">Selector ${escapeHtml(domain.selector)}</span>
         </div>
       </div>
-      <div class="detail-actions">
-        <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 class="card-actions detail-actions">
+        <button class="btn btn-primary" data-action="apply-dns" title="${escapeAttr(applyTitle)}" type="button" ${applyDisabled}>一键配置 DNS</button>
+        <button class="btn btn-outline-secondary" data-action="check" type="button">立即检查</button>
+        <button class="btn btn-outline-secondary" data-action="rotate-dkim" type="button">轮换 DKIM</button>
+        <button class="btn btn-outline-danger" data-action="delete-domain" type="button">删除</button>
       </div>
     </div>
-    <div class="detail-body">
+    <div class="card-body detail-body">
       ${renderSetupOverview(domain, guide, records)}
       ${guide.apply ? renderApplyResult(guide.apply) : ''}
       ${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 class="row row-cards">
+        <section class="col-lg-7">
+          <div class="card subpanel">
+            <div class="card-header">
+              <h3 class="card-title">配置引导</h3>
+              <div class="card-actions"><button class="btn btn-sm btn-outline-secondary" data-action="copy-all-dns" type="button">复制全部</button></div>
+            </div>
+            <div class="card-body">
+              ${renderDnsGuide(records)}
+            </div>
           </div>
-          ${renderDnsGuide(records)}
         </section>
-        <section class="subpanel">
-          <h3>域名设置</h3>
-          ${renderSettingsForm(domain)}
+        <section class="col-lg-5">
+          <div class="card subpanel">
+            <div class="card-header"><h3 class="card-title">域名设置</h3></div>
+            <div class="card-body">${renderSettingsForm(domain)}</div>
+          </div>
         </section>
       </div>
-      <div class="grid-two">
-        <section class="subpanel">
-          <h3>当前 DNS</h3>
-          ${renderLiveDns(guide.live)}
+      <div class="row row-cards">
+        <section class="col-lg-6">
+          <div class="card subpanel">
+            <div class="card-header"><h3 class="card-title">当前 DNS</h3></div>
+            <div class="card-body">${renderLiveDns(guide.live)}</div>
+          </div>
         </section>
-        <section class="subpanel">
-          <h3>测试发送</h3>
-          ${renderSendForm(domain)}
+        <section class="col-lg-6">
+          <div class="card subpanel">
+            <div class="card-header"><h3 class="card-title">测试发送</h3></div>
+            <div class="card-body">${renderSendForm(domain)}</div>
+          </div>
         </section>
       </div>
-      <div class="grid-two">
-        <section class="subpanel api-box">
-          <h3>发送 API</h3>
-          <pre><code>${escapeHtml(apiExample(domain))}</code></pre>
+      <div class="row row-cards">
+        <section class="col-lg-6">
+          <div class="card subpanel api-box">
+            <div class="card-header"><h3 class="card-title">发送 API</h3></div>
+            <div class="card-body"><pre><code>${escapeHtml(apiExample(domain))}</code></pre></div>
+          </div>
         </section>
-        <section class="subpanel">
-          <h3>SMTP 发信</h3>
-          ${renderSmtpBox(domain)}
+        <section class="col-lg-6">
+          <div class="card subpanel">
+            <div class="card-header"><h3 class="card-title">SMTP 发信</h3></div>
+            <div class="card-body">${renderSmtpBox(domain)}</div>
+          </div>
         </section>
       </div>
-      <section class="subpanel">
-        <h3>最近发送</h3>
-        ${renderEvents(domain)}
+      <section class="card subpanel">
+        <div class="card-header"><h3 class="card-title">最近发送</h3></div>
+        <div class="card-body">${renderEvents(domain)}</div>
       </section>
-      <section class="subpanel">
-        <h3>增强安全记录</h3>
-        ${renderOptionalRecords(guide.optionalRecords || [])}
+      <section class="card subpanel">
+        <div class="card-header"><h3 class="card-title">增强安全记录</h3></div>
+        <div class="card-body">${renderOptionalRecords(guide.optionalRecords || [])}</div>
       </section>
     </div>
   `;
@@ -367,7 +574,7 @@ function renderSetupOverview(domain, guide, records) {
   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 compact"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
   }).join('');
   const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
   return `
@@ -375,7 +582,7 @@ function renderSetupOverview(domain, guide, records) {
       <div>
         <span class="eyebrow">Sending domain</span>
         <h3>${escapeHtml(domain.domain)}</h3>
-        <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}</p>
+        <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp || '未设置 IP')}</p>
         <p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
       </div>
       <div class="summary-grid">${cards}</div>
@@ -386,17 +593,17 @@ function renderSetupOverview(domain, guide, records) {
 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>
+    <section class="card subpanel">
+      <div class="card-header">
+        <h3 class="card-title">一键配置结果</h3>
+        <div class="card-actions">${badge({ className: apply.ok ? 'ok' : 'warn', label: apply.ok ? '完成' : '部分失败' })}</div>
       </div>
-      <div class="mini-list">
+      <div class="card-body 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>
+              <span class="text-secondary">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
             </div>
             ${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
           </div>
@@ -408,7 +615,12 @@ function renderApplyResult(apply) {
 
 function renderDnsGuide(records) {
   if (!records.length) {
-    return `<div class="empty-guide"><h4>尚未生成检查结果</h4><p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p></div>`;
+    return `
+      <div class="empty">
+        <div class="empty-title">尚未生成检查结果</div>
+        <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
+      </div>
+    `;
   }
   return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
 }
@@ -428,7 +640,7 @@ function renderRecordCard(record, index) {
         <div class="dns-value">
           <span>目标值</span>
           <code>${escapeHtml(record.value || '')}</code>
-          <button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
+          <button class="btn btn-sm btn-outline-secondary" 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>` : ''}
@@ -438,10 +650,10 @@ function renderRecordCard(record, index) {
 }
 
 function renderRecordTable(records) {
-  if (!records.length) return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
+  if (!records.length) return '<p class="text-secondary">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
   return `
     <div class="record-table-wrap">
-      <table>
+      <table class="table table-vcenter">
         <thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
         <tbody>${records.map(renderRecordRow).join('')}</tbody>
       </table>
@@ -458,69 +670,78 @@ function renderRecordRow(record) {
       <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>
+      <td><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
     </tr>
   `;
 }
 
 function renderSettingsForm(domain) {
   const dnsOptions = [
-    '<option value="">不绑定</option>',
+    '<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>
+      <div class="mb-3">
+        <label class="form-label">DNS API</label>
+        <select class="form-select" name="dnsCredentialId">${dnsOptions}</select>
+        <div class="form-hint">绑定后才能使用一键配置 DNS。</div>
       </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>
+      <div class="row">
+        <div class="col-sm-6 mb-3">
+          <label class="form-label">DKIM selector</label>
+          <input class="form-control" name="selector" value="${escapeAttr(domain.selector)}">
+          <div class="form-hint">轮换 DKIM 时也会重新生成 selector。</div>
+        </div>
+        <div class="col-sm-6 mb-3">
+          <label class="form-label">DMARC 策略</label>
+          <select class="form-select" name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
+        </div>
+      </div>
+      <div class="mb-3"><label class="form-label">发信主机</label><input class="form-control" name="senderHost" value="${escapeAttr(domain.senderHost)}"></div>
+      <div class="mb-3"><label class="form-label">发信 IP</label><input class="form-control" name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></div>
+      <div class="mb-3"><label class="form-label">兼容第三方 SPF</label><textarea class="form-control" name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></div>
+      <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>
+      <button class="btn btn-outline-secondary" type="submit">保存设置</button>
     </form>
   `;
 }
 
 function renderLiveDns(live) {
-  if (!live) return '<p class="muted">暂无检查数据</p>';
+  if (!live) return '<p class="text-secondary">暂无检查数据</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>`;
+  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>`;
 }
 
 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>
+      <div class="mb-3"><label class="form-label">From</label><input class="form-control" name="from" value="noreply@${escapeAttr(domain.domain)}"></div>
+      <div class="mb-3"><label class="form-label">To</label><input class="form-control" name="to" placeholder="user@example.com" required></div>
+      <div class="mb-3"><label class="form-label">Subject</label><input class="form-control" name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></div>
+      <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>
+      <button class="btn btn-primary" 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>`;
+  if (!events.length) return '<p class="text-secondary">暂无发送记录</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="text-secondary">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span><span class="text-secondary">${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="text-secondary">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>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>
+      <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>
       <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>
@@ -528,7 +749,7 @@ function renderSmtpBox(domain) {
 }
 
 function renderOptionalRecords(records) {
-  if (!records.length) return '<p class="muted">完成基础发信配置后可逐步启用。</p>';
+  if (!records.length) return '<p class="text-secondary">完成基础发信配置后可逐步启用。</p>';
   return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
 }
 
@@ -584,15 +805,55 @@ 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();
+    const id = Number(data.id || 0);
+    delete data.id;
+    const path = id ? `/api/dns-credentials/${id}` : '/api/dns-credentials';
+    const method = id ? 'PATCH' : 'POST';
+    const result = await api(path, { method, body: JSON.stringify(data) });
+    if (id) {
+      state.dnsCredentials = state.dnsCredentials.map((item) => item.id === id ? result.credential : item);
+    } else {
+      state.dnsCredentials.unshift(result.credential);
+    }
+    resetDnsCredentialForm();
     renderDefaults();
     render();
-    toast('DNS 凭据已保存');
+    toast(id ? 'DNS API 已更新' : 'DNS API 已新增');
   });
 }
 
+function editDnsCredential(id) {
+  const credential = state.dnsCredentials.find((item) => item.id === id);
+  if (!credential) return;
+  setView('dns');
+  resetDnsCredentialForm();
+  els.dnsCredentialIdField.value = credential.id;
+  els.dnsCredentialName.value = credential.name || '';
+  els.dnsProvider.value = credential.provider || 'cloudflare';
+  els.dnsZoneName.value = credential.zoneName || '';
+  els.dnsDefaultTtl.value = credential.defaultTtl || 600;
+  els.dnsCredentialFormTitle.textContent = `编辑 ${credential.name}`;
+  els.dnsCredentialFormHint.textContent = '密钥字段留空会保留原值。';
+  toggleDnsProviderFields();
+  els.dnsCredentialName.focus();
+}
+
+function resetDnsCredentialForm() {
+  els.dnsCredentialForm.reset();
+  els.dnsCredentialIdField.value = '';
+  els.dnsDefaultTtl.value = 600;
+  els.dnsCredentialFormTitle.textContent = '新增 DNS API';
+  els.dnsCredentialFormHint.textContent = '选择服务商后只填写对应字段。';
+  toggleDnsProviderFields();
+}
+
+function toggleDnsProviderFields() {
+  const provider = els.dnsProvider.value || 'cloudflare';
+  for (const fieldset of document.querySelectorAll('[data-provider-field]')) {
+    fieldset.classList.toggle('hidden', fieldset.dataset.providerField !== provider);
+  }
+}
+
 async function createApiToken(event) {
   event.preventDefault();
   await mutate(async () => {
@@ -618,6 +879,12 @@ function generateSmtpPassword() {
 }
 
 async function handleGlobalClick(event) {
+  const viewButton = event.target.closest('[data-view]');
+  if (viewButton && !event.target.closest('[data-domain-id]')) {
+    event.preventDefault();
+    setView(viewButton.dataset.view);
+    return;
+  }
   const copyButton = event.target.closest('[data-copy]');
   if (copyButton) {
     event.preventDefault();
@@ -627,18 +894,20 @@ async function handleGlobalClick(event) {
     toast('已复制');
     return;
   }
-  const action = event.target.closest('[data-action]')?.dataset.action;
+  const actionTarget = event.target.closest('[data-action]');
+  const action = actionTarget?.dataset.action;
   if (!action) return;
-  const id = Number(event.target.closest('[data-id]')?.dataset.id || 0);
+  const id = Number(actionTarget.closest('[data-id]')?.dataset.id || actionTarget.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();
+  if (action === 'edit-dns') return editDnsCredential(id);
   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);
+  if (action === 'toggle-user') return toggleUser(id, actionTarget.closest('[data-status]')?.dataset.status);
 }
 
 async function handleDetailSubmit(event) {
@@ -689,6 +958,7 @@ async function sendTest(form) {
     await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
     const events = await api('/api/events');
     state.events = events.events || [];
+    renderDefaults();
     render();
     toast('已提交到发信队列');
   });
@@ -725,6 +995,7 @@ async function deleteSelected() {
     await api(`/api/domains/${id}`, { method: 'DELETE' });
     state.domains = state.domains.filter((item) => item.id !== id);
     state.selectedId = state.domains[0]?.id || null;
+    renderDefaults();
     render();
   });
 }
@@ -737,11 +1008,13 @@ async function testDnsCredential(id) {
 }
 
 async function deleteDnsCredential(id) {
-  if (!confirm('删除该 DNS API 凭据?')) return;
+  const credential = state.dnsCredentials.find((item) => item.id === id);
+  if (!confirm(`删除 ${credential?.name || '该 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);
+    if (Number(els.dnsCredentialIdField.value || 0) === id) resetDnsCredentialForm();
     renderDefaults();
     render();
   });
@@ -789,7 +1062,7 @@ async function mutate(fn) {
   try {
     await fn();
   } catch (error) {
-    toast(error.message);
+    toast(error.message, 'error');
   } finally {
     setBusy(false);
   }
@@ -825,7 +1098,17 @@ function statusMeta(target) {
 }
 
 function badge(meta) {
-  return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
+  return `<span class="badge ${badgeTone(meta.className)}">${escapeHtml(meta.label)}</span>`;
+}
+
+function badgeTone(className) {
+  return {
+    ok: 'bg-green-lt',
+    warn: 'bg-yellow-lt',
+    missing: 'bg-red-lt',
+    failed: 'bg-red-lt',
+    idle: 'bg-secondary-lt'
+  }[className] || 'bg-secondary-lt';
 }
 
 function providerLabel(provider) {
@@ -836,18 +1119,15 @@ function setBusy(value) {
   state.busy = value;
   document.body.classList.toggle('busy', value);
   els.refreshButton.disabled = value;
+  if (els.refreshButtonMobile) els.refreshButtonMobile.disabled = value;
 }
 
-function toast(message) {
+function toast(message, tone = 'info') {
   const node = document.createElement('div');
-  node.className = 'notice';
+  node.className = `toast ${tone}`;
   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);
+  els.toastHost.appendChild(node);
+  setTimeout(() => node.remove(), 3200);
 }
 
 function formatDate(value) {

+ 326 - 156
public/index.html

@@ -4,180 +4,350 @@
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>MailHub</title>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/css/tabler.min.css">
     <link rel="stylesheet" href="/styles.css">
   </head>
   <body>
-    <main class="app-shell">
-      <header class="topbar">
-        <div>
-          <h1>MailHub</h1>
-          <p id="runtimeLine">加载中</p>
-        </div>
-        <div class="topbar-actions">
-          <button class="icon-button" id="refreshButton" title="刷新">刷新</button>
-          <button class="icon-button" id="logoutButton" title="退出登录">退出</button>
-        </div>
-      </header>
-
-      <section class="notice hidden" id="securityNotice"></section>
+    <div class="page">
+      <aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
+        <div class="container-fluid">
+          <h1 class="navbar-brand navbar-brand-autodark">
+            <span class="brand-mark">MH</span>
+            <span>MailHub</span>
+          </h1>
+          <div class="navbar-nav flex-row d-lg-none">
+            <button class="btn btn-ghost-light" id="refreshButtonMobile" type="button">刷新</button>
+          </div>
+          <div class="collapse navbar-collapse show">
+            <ul class="navbar-nav pt-lg-3 primary-nav" aria-label="主导航">
+              <li class="nav-item">
+                <button class="nav-link active" data-view="domains" type="button">
+                  <span class="nav-link-title">发信域名</span>
+                  <span class="badge bg-blue-lt ms-auto" id="domainCount">0</span>
+                </button>
+              </li>
+              <li class="nav-item">
+                <button class="nav-link" data-view="dns" type="button">
+                  <span class="nav-link-title">DNS API</span>
+                  <span class="badge bg-azure-lt ms-auto" id="dnsCredentialCount">0</span>
+                </button>
+              </li>
+              <li class="nav-item">
+                <button class="nav-link" data-view="smtp" type="button">
+                  <span class="nav-link-title">SMTP 凭据</span>
+                  <span class="badge bg-secondary-lt ms-auto" id="smtpCredentialState">未配置</span>
+                </button>
+              </li>
+              <li class="nav-item">
+                <button class="nav-link" data-view="tokens" type="button">
+                  <span class="nav-link-title">API Token</span>
+                  <span class="badge bg-secondary-lt ms-auto" id="apiTokenCount">0</span>
+                </button>
+              </li>
+              <li class="nav-item hidden" id="adminNavButton">
+                <button class="nav-link" data-view="admin" type="button">
+                  <span class="nav-link-title">系统设置</span>
+                  <span class="badge bg-green-lt ms-auto">Admin</span>
+                </button>
+              </li>
+            </ul>
 
-      <div class="workspace">
-        <aside class="sidebar">
-          <section class="panel">
-            <div class="section-head">
-              <h2>账号</h2>
-              <span class="badge idle" id="userRole">加载中</span>
+            <div class="sidebar-block">
+              <div class="d-flex align-items-center justify-content-between mb-2">
+                <h2 class="h4 m-0">当前账号</h2>
+                <span class="badge bg-secondary-lt" id="userRole">加载中</span>
+              </div>
+              <div class="list-group list-group-flush" id="accountBox"></div>
             </div>
-            <div class="live-list" id="accountBox"></div>
-          </section>
 
-          <form class="panel compact-form" id="addDomainForm">
-            <h2>添加发信域名</h2>
-            <label>
-              域名
-              <input name="domain" placeholder="example.com" autocomplete="off" required>
-            </label>
-            <label>
-              DNS API
-              <select name="dnsCredentialId" id="domainDnsCredential"></select>
-            </label>
-            <div class="form-grid">
-              <label>
-                DKIM selector
-                <input name="selector" placeholder="mh202607">
-              </label>
-              <label>
-                DMARC 策略
-                <select name="dmarcPolicy">
-                  <option value="none">none</option>
-                  <option value="quarantine">quarantine</option>
-                  <option value="reject">reject</option>
-                </select>
-              </label>
+            <div class="sidebar-block">
+              <div class="d-flex align-items-center justify-content-between mb-2">
+                <h2 class="h4 m-0">发信域名</h2>
+                <button class="btn btn-sm btn-outline-light" data-view="domains" type="button">新增</button>
+              </div>
+              <div class="domain-list" id="domainList"></div>
             </div>
-            <label>
-              发信主机
-              <input name="senderHost" id="defaultSenderHost" autocomplete="off">
-            </label>
-            <label>
-              发信 IP
-              <input name="sendingIp" id="defaultSendingIp" autocomplete="off">
-            </label>
-            <label>
-              兼容第三方 SPF
-              <textarea name="spfExtra" id="defaultSpfExtra" rows="2"></textarea>
-            </label>
-            <button class="primary-button" type="submit">添加并生成 DNS</button>
-          </form>
+          </div>
+        </div>
+      </aside>
 
-          <form class="panel compact-form" id="smtpCredentialForm">
-            <div class="section-head">
-              <h2>SMTP 凭据</h2>
-              <span class="badge idle" id="smtpCredentialState">未加载</span>
+      <div class="page-wrapper">
+        <header class="navbar navbar-expand-md d-print-none topbar">
+          <div class="container-xl">
+            <div>
+              <div class="text-uppercase text-secondary fw-bold small" id="viewEyebrow">Domains</div>
+              <h2 class="page-title" id="viewTitle">发信域名</h2>
+              <div class="text-secondary" id="runtimeLine">加载中</div>
             </div>
-            <label>
-              用户名
-              <input name="username" id="smtpUsername" autocomplete="off" required>
-            </label>
-            <label>
-              新密码
-              <input name="password" id="smtpPassword" type="password" autocomplete="new-password" placeholder="留空则不修改">
-            </label>
-            <div class="button-row">
-              <button class="secondary-button" id="generateSmtpPassword" type="button">生成密码</button>
-              <button class="primary-button" type="submit">保存凭据</button>
+            <div class="navbar-nav flex-row order-md-last gap-2">
+              <button class="btn btn-outline-secondary" id="refreshButton" type="button">刷新</button>
+              <button class="btn btn-outline-secondary" id="logoutButton" type="button">退出</button>
             </div>
-            <div class="credential-copy" id="smtpCredentialCopy"></div>
-          </form>
+          </div>
+        </header>
 
-          <form class="panel compact-form" id="dnsCredentialForm">
-            <div class="section-head">
-              <h2>DNS API</h2>
-              <span class="count" id="dnsCredentialCount">0</span>
-            </div>
-            <label>
-              名称
-              <input name="name" placeholder="Cloudflare 主账号" required>
-            </label>
-            <div class="form-grid">
-              <label>
-                服务商
-                <select name="provider" required>
-                  <option value="cloudflare">Cloudflare</option>
-                  <option value="aliyun">阿里云 DNS</option>
-                  <option value="dnspod">腾讯云 DNSPod</option>
-                </select>
-              </label>
-              <label>
-                TTL
-                <input name="defaultTtl" type="number" min="60" max="86400" value="600">
-              </label>
-            </div>
-            <label>
-              Zone / 根域名
-              <input name="zoneName" placeholder="example.com" autocomplete="off" required>
-            </label>
-            <label>
-              Cloudflare API Token
-              <input name="apiToken" autocomplete="off">
-            </label>
-            <label>
-              Cloudflare Zone ID
-              <input name="zoneId" autocomplete="off">
-            </label>
-            <label>
-              阿里云 AccessKeyId
-              <input name="accessKeyId" autocomplete="off">
-            </label>
-            <label>
-              阿里云 AccessKeySecret
-              <input name="accessKeySecret" autocomplete="off">
-            </label>
-            <label>
-              腾讯云 SecretId
-              <input name="secretId" autocomplete="off">
-            </label>
-            <label>
-              腾讯云 SecretKey
-              <input name="secretKey" autocomplete="off">
-            </label>
-            <button class="primary-button" type="submit">保存 DNS 凭据</button>
-            <div class="mini-list" id="dnsCredentialList"></div>
-          </form>
+        <main class="page-body">
+          <div class="container-xl">
+            <div class="alert alert-danger hidden" id="securityNotice"></div>
 
-          <form class="panel compact-form" id="apiTokenForm">
-            <div class="section-head">
-              <h2>发送 API Token</h2>
-              <span class="count" id="apiTokenCount">0</span>
-            </div>
-            <label>
-              名称
-              <input name="name" placeholder="Production sender" required>
-            </label>
-            <button class="primary-button" type="submit">生成 Token</button>
-            <div class="mini-list" id="apiTokenList"></div>
-          </form>
+            <section class="view active" id="domainsView" data-view-panel="domains">
+              <div class="row row-cards mb-3">
+                <div class="col-lg-5">
+                  <form class="card compact-form" id="addDomainForm">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title">添加发信域名</h3>
+                        <p class="card-subtitle">生成 DKIM、SPF、DMARC 记录;绑定 DNS API 后可一键写入。</p>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="mb-3">
+                        <label class="form-label">域名</label>
+                        <input class="form-control" name="domain" placeholder="example.com" autocomplete="off" required>
+                        <div class="form-hint">填写根域名,不需要带 http 或路径。</div>
+                      </div>
+                      <div class="mb-3">
+                        <label class="form-label">DNS API</label>
+                        <select class="form-select" name="dnsCredentialId" id="domainDnsCredential"></select>
+                        <div class="form-hint">为空时仍可添加域名,稍后在域名设置里绑定。</div>
+                      </div>
+                      <div class="row">
+                        <div class="col-sm-6 mb-3">
+                          <label class="form-label">DKIM selector</label>
+                          <input class="form-control" name="selector" placeholder="mh202607">
+                          <div class="form-hint">默认按当前月份生成。</div>
+                        </div>
+                        <div class="col-sm-6 mb-3">
+                          <label class="form-label">DMARC 策略</label>
+                          <select class="form-select" name="dmarcPolicy">
+                            <option value="none">none</option>
+                            <option value="quarantine">quarantine</option>
+                            <option value="reject">reject</option>
+                          </select>
+                          <div class="form-hint">新域名建议先用 none。</div>
+                        </div>
+                      </div>
+                      <div class="row">
+                        <div class="col-sm-6 mb-3">
+                          <label class="form-label">发信主机</label>
+                          <input class="form-control" name="senderHost" id="defaultSenderHost" autocomplete="off">
+                          <div class="form-hint">用于 A 记录和 SMTP HELO。</div>
+                        </div>
+                        <div class="col-sm-6 mb-3">
+                          <label class="form-label">发信 IP</label>
+                          <input class="form-control" name="sendingIp" id="defaultSendingIp" autocomplete="off">
+                          <div class="form-hint">服务器公网 IPv4。</div>
+                        </div>
+                      </div>
+                      <div class="mb-3">
+                        <label class="form-label">兼容第三方 SPF</label>
+                        <textarea class="form-control" name="spfExtra" id="defaultSpfExtra" rows="2"></textarea>
+                        <div class="form-hint">例如 include:spf.mailjet.com,会被合并进唯一 SPF。</div>
+                      </div>
+                    </div>
+                    <div class="card-footer text-end">
+                      <button class="btn btn-primary" type="submit">添加域名</button>
+                    </div>
+                  </form>
+                </div>
 
-          <section class="panel domain-panel">
-            <div class="section-head">
-              <h2>域名</h2>
-              <span class="count" id="domainCount">0</span>
-            </div>
-            <div class="domain-list" id="domainList"></div>
-          </section>
+                <div class="col-lg-7">
+                  <section class="card h-100" id="domainReadiness"></section>
+                </div>
+              </div>
+
+              <section class="card main-panel" id="detailPanel">
+                <div class="empty-state">
+                  <h2>选择或添加一个域名</h2>
+                  <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
+                </div>
+              </section>
+            </section>
+
+            <section class="view" id="dnsView" data-view-panel="dns">
+              <div class="row row-cards">
+                <div class="col-lg-7">
+                  <section class="card">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title">DNS API 凭据</h3>
+                        <p class="card-subtitle">每个根域名或账号可以单独保存一组凭据,并按域名绑定使用。</p>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="credential-list" id="dnsCredentialList"></div>
+                    </div>
+                  </section>
+                </div>
+
+                <div class="col-lg-5">
+                  <form class="card compact-form" id="dnsCredentialForm">
+                    <input name="id" id="dnsCredentialIdField" type="hidden">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title" id="dnsCredentialFormTitle">新增 DNS API</h3>
+                        <p class="card-subtitle" id="dnsCredentialFormHint">选择服务商后只填写对应字段。</p>
+                      </div>
+                      <div class="card-actions">
+                        <button class="btn btn-sm btn-outline-secondary" id="resetDnsCredentialForm" type="button">清空</button>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="mb-3">
+                        <label class="form-label">名称</label>
+                        <input class="form-control" name="name" id="dnsCredentialName" placeholder="Cloudflare 主账号" required>
+                        <div class="form-hint">显示在域名绑定下拉中,建议写清账号或用途。</div>
+                      </div>
+                      <div class="row">
+                        <div class="col-sm-7 mb-3">
+                          <label class="form-label">服务商</label>
+                          <select class="form-select" name="provider" id="dnsProvider" required>
+                            <option value="cloudflare">Cloudflare</option>
+                            <option value="aliyun">阿里云 DNS</option>
+                            <option value="dnspod">腾讯云 DNSPod</option>
+                          </select>
+                        </div>
+                        <div class="col-sm-5 mb-3">
+                          <label class="form-label">TTL</label>
+                          <input class="form-control" name="defaultTtl" id="dnsDefaultTtl" type="number" min="60" max="86400" value="600">
+                        </div>
+                      </div>
+                      <div class="mb-3">
+                        <label class="form-label">Zone / 根域名</label>
+                        <input class="form-control" name="zoneName" id="dnsZoneName" placeholder="example.com" autocomplete="off" required>
+                        <div class="form-hint">Cloudflare 可填 Zone ID 或根域名;阿里云和 DNSPod 需要根域名。</div>
+                      </div>
+                      <div class="provider-fields" data-provider-field="cloudflare">
+                        <div class="mb-3">
+                          <label class="form-label">Cloudflare API Token</label>
+                          <input class="form-control" name="apiToken" autocomplete="off" placeholder="仅保存时填写">
+                          <div class="form-hint">需要 Zone DNS Edit 权限。</div>
+                        </div>
+                        <div class="mb-3">
+                          <label class="form-label">Cloudflare Zone ID</label>
+                          <input class="form-control" name="zoneId" autocomplete="off" placeholder="可选">
+                        </div>
+                      </div>
+                      <div class="provider-fields hidden" data-provider-field="aliyun">
+                        <div class="mb-3">
+                          <label class="form-label">阿里云 AccessKeyId</label>
+                          <input class="form-control" name="accessKeyId" autocomplete="off" placeholder="仅保存时填写">
+                        </div>
+                        <div class="mb-3">
+                          <label class="form-label">阿里云 AccessKeySecret</label>
+                          <input class="form-control" name="accessKeySecret" autocomplete="off" placeholder="仅保存时填写">
+                        </div>
+                      </div>
+                      <div class="provider-fields hidden" data-provider-field="dnspod">
+                        <div class="mb-3">
+                          <label class="form-label">腾讯云 SecretId</label>
+                          <input class="form-control" name="secretId" autocomplete="off" placeholder="仅保存时填写">
+                        </div>
+                        <div class="mb-3">
+                          <label class="form-label">腾讯云 SecretKey</label>
+                          <input class="form-control" name="secretKey" autocomplete="off" placeholder="仅保存时填写">
+                        </div>
+                      </div>
+                    </div>
+                    <div class="card-footer text-end">
+                      <button class="btn btn-primary" type="submit">保存 DNS API</button>
+                    </div>
+                  </form>
+                </div>
+              </div>
+            </section>
+
+            <section class="view" id="smtpView" data-view-panel="smtp">
+              <div class="row row-cards">
+                <div class="col-lg-5">
+                  <form class="card compact-form" id="smtpCredentialForm">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title">SMTP 凭据</h3>
+                        <p class="card-subtitle">用于 SMTP Submission 认证,保存后可复制给发信客户端。</p>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="mb-3">
+                        <label class="form-label">用户名</label>
+                        <input class="form-control" name="username" id="smtpUsername" autocomplete="off" required>
+                      </div>
+                      <div class="mb-3">
+                        <label class="form-label">新密码</label>
+                        <input class="form-control" name="password" id="smtpPassword" type="password" autocomplete="new-password" placeholder="留空则不修改">
+                      </div>
+                    </div>
+                    <div class="card-footer d-flex justify-content-between">
+                      <button class="btn btn-outline-secondary" id="generateSmtpPassword" type="button">生成密码</button>
+                      <button class="btn btn-primary" type="submit">保存凭据</button>
+                    </div>
+                  </form>
+                </div>
+
+                <div class="col-lg-7">
+                  <section class="card">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title">可复制信息</h3>
+                        <p class="card-subtitle">旧数据只有哈希时,需要重新设置一次密码才能回显。</p>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="credential-copy" id="smtpCredentialCopy"></div>
+                    </div>
+                  </section>
+                </div>
+              </div>
+            </section>
+
+            <section class="view" id="tokensView" data-view-panel="tokens">
+              <div class="row row-cards">
+                <div class="col-lg-4">
+                  <form class="card compact-form" id="apiTokenForm">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title">发送 API Token</h3>
+                        <p class="card-subtitle">Token 只在生成时完整显示一次。</p>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="mb-3">
+                        <label class="form-label">名称</label>
+                        <input class="form-control" name="name" placeholder="Production sender" required>
+                      </div>
+                    </div>
+                    <div class="card-footer text-end">
+                      <button class="btn btn-primary" type="submit">生成 Token</button>
+                    </div>
+                  </form>
+                </div>
 
-          <section class="panel hidden" id="adminPanel"></section>
-        </aside>
+                <div class="col-lg-8">
+                  <section class="card">
+                    <div class="card-header">
+                      <div>
+                        <h3 class="card-title">Token 列表</h3>
+                        <p class="card-subtitle">按用途拆分 Token,泄露时可以单独删除。</p>
+                      </div>
+                    </div>
+                    <div class="card-body">
+                      <div class="mini-list" id="apiTokenList"></div>
+                    </div>
+                  </section>
+                </div>
+              </div>
+            </section>
 
-        <section class="main-panel" id="detailPanel">
-          <div class="empty-state">
-            <h2>选择或添加一个域名</h2>
-            <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
+            <section class="view" id="adminView" data-view-panel="admin">
+              <section class="card hidden" id="adminPanel"></section>
+            </section>
           </div>
-        </section>
+        </main>
       </div>
-    </main>
+    </div>
 
+    <div class="toast-host" id="toastHost"></div>
+    <script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0/dist/js/tabler.min.js"></script>
     <script src="/app.js" type="module"></script>
   </body>
 </html>

+ 231 - 557
public/styles.css

@@ -1,647 +1,372 @@
 :root {
-  color-scheme: light;
-  --bg: #f6f7f9;
-  --panel: #ffffff;
-  --ink: #1c2530;
-  --muted: #657183;
-  --line: #d8dde5;
-  --line-strong: #b7c0cc;
-  --blue: #1d5fd1;
-  --green: #16834c;
-  --amber: #9a5a00;
-  --red: #b3261e;
-  --teal: #0b7285;
-  --shadow: 0 12px 35px rgba(31, 45, 61, 0.08);
-}
-
-* {
-  box-sizing: border-box;
-}
-
-body {
-  margin: 0;
-  background: var(--bg);
-  color: var(--ink);
-  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-  line-height: 1.45;
-}
-
-button,
-input,
-textarea,
-select {
-  font: inherit;
+  --mh-sidebar: #142033;
+  --mh-sidebar-soft: #1c2b42;
+  --mh-line: #dce3ec;
+  --mh-ink: #172033;
+  --mh-muted: #66758a;
+  --mh-blue: #206bc4;
+  --mh-green: #2fb344;
+  --mh-yellow: #f59f00;
+  --mh-red: #d63939;
 }
 
-button {
-  cursor: pointer;
+.navbar-vertical {
+  background: var(--mh-sidebar);
 }
 
-.app-shell {
-  min-height: 100vh;
-  padding: 20px;
+.navbar-brand {
+  gap: 0.7rem;
+  justify-content: flex-start;
 }
 
-.topbar {
+.brand-mark {
   align-items: center;
-  display: flex;
-  justify-content: space-between;
-  gap: 16px;
-  margin: 0 auto 16px;
-  max-width: 1480px;
-}
-
-h1,
-h2,
-h3,
-p {
-  margin: 0;
-}
-
-h1 {
-  font-size: 26px;
-  letter-spacing: 0;
-}
-
-h2 {
-  font-size: 16px;
-  letter-spacing: 0;
-}
-
-h3 {
-  font-size: 15px;
-  letter-spacing: 0;
-}
-
-.topbar p,
-.muted {
-  color: var(--muted);
-  font-size: 13px;
-}
-
-.topbar-actions {
-  display: flex;
-  gap: 8px;
-}
-
-.workspace {
-  display: grid;
-  gap: 16px;
-  grid-template-columns: 360px minmax(0, 1fr);
-  margin: 0 auto;
-  max-width: 1480px;
-}
-
-.sidebar {
-  display: grid;
-  gap: 16px;
-  align-content: start;
-}
-
-.panel,
-.main-panel {
-  background: var(--panel);
-  border: 1px solid var(--line);
-  border-radius: 8px;
-  box-shadow: var(--shadow);
-}
-
-.panel {
-  padding: 16px;
-}
-
-.main-panel {
-  min-height: 720px;
-  overflow: hidden;
-}
-
-.compact-form {
-  display: grid;
-  gap: 12px;
-}
-
-label {
-  color: var(--muted);
-  display: grid;
-  font-size: 12px;
-  gap: 6px;
-}
-
-input,
-textarea,
-select {
-  background: #fff;
-  border: 1px solid var(--line-strong);
-  border-radius: 6px;
-  color: var(--ink);
-  min-width: 0;
-  padding: 9px 10px;
-  width: 100%;
-}
-
-textarea {
-  resize: vertical;
-}
-
-input:focus,
-textarea:focus,
-select:focus {
-  border-color: var(--blue);
-  outline: 3px solid rgba(29, 95, 209, 0.15);
-}
-
-.form-grid {
-  display: grid;
-  gap: 10px;
-  grid-template-columns: 1fr 1fr;
-}
-
-.primary-button,
-.secondary-button,
-.small-button,
-.icon-button,
-.danger-button {
-  align-items: center;
-  border-radius: 6px;
-  border: 1px solid transparent;
+  background: #ffffff;
+  border-radius: 0.5rem;
+  color: var(--mh-sidebar);
   display: inline-flex;
+  font-weight: 800;
+  height: 2rem;
   justify-content: center;
-  min-height: 38px;
-  padding: 0 12px;
+  letter-spacing: 0;
+  width: 2rem;
 }
 
-.primary-button {
-  background: var(--blue);
-  color: #fff;
+.primary-nav .nav-link {
+  border: 0;
+  text-align: left;
+  width: 100%;
 }
 
-.secondary-button,
-.icon-button {
-  background: #eef3fb;
-  border-color: #c9d7ef;
-  color: #173b78;
+.primary-nav .nav-link.active {
+  background: var(--mh-sidebar-soft);
 }
 
-.small-button {
-  background: #f8fafc;
-  border-color: var(--line);
-  color: var(--ink);
-  min-height: 30px;
-  padding: 0 9px;
+.sidebar-block {
+  border-top: 1px solid rgba(255, 255, 255, 0.12);
+  color: #d8e2ef;
+  margin-top: 1rem;
+  padding-top: 1rem;
 }
 
-.danger-button {
-  background: #fff5f4;
-  border-color: #f0b6b1;
-  color: var(--red);
+.sidebar-block code {
+  background: rgba(255, 255, 255, 0.08);
+  border: 1px solid rgba(255, 255, 255, 0.14);
+  color: #ffffff;
 }
 
-.section-head {
+.topbar .container-xl {
   align-items: center;
   display: flex;
   justify-content: space-between;
-  margin-bottom: 12px;
 }
 
-.count,
-.badge {
-  border-radius: 999px;
-  display: inline-flex;
-  font-size: 12px;
-  line-height: 1;
-  padding: 5px 8px;
+.view {
+  display: none;
+}
+
+.view.active {
+  display: block;
 }
 
-.count {
-  background: #ecf2f8;
-  color: #38516f;
+.hidden {
+  display: none !important;
 }
 
 .domain-list {
   display: grid;
-  gap: 8px;
+  gap: 0.5rem;
 }
 
 .domain-item {
-  background: #fbfcfe;
-  border: 1px solid var(--line);
-  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.06);
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 0.5rem;
+  color: #ffffff;
   display: grid;
-  gap: 6px;
-  padding: 10px;
+  gap: 0.25rem;
+  padding: 0.65rem 0.75rem;
   text-align: left;
   width: 100%;
 }
 
+.domain-item:hover,
 .domain-item.active {
-  border-color: var(--blue);
-  outline: 3px solid rgba(29, 95, 209, 0.12);
+  background: rgba(255, 255, 255, 0.12);
+  border-color: rgba(255, 255, 255, 0.28);
 }
 
-.domain-item strong {
-  color: var(--ink);
-  font-size: 14px;
+.domain-item span,
+.domain-item small {
   overflow-wrap: anywhere;
 }
 
-.item-line {
-  align-items: center;
-  display: flex;
-  justify-content: space-between;
-  gap: 8px;
-}
-
-.badge.ok {
-  background: #e8f6ee;
-  color: var(--green);
+.domain-item small {
+  color: #9fb2ca;
 }
 
-.badge.warn {
-  background: #fff4df;
-  color: var(--amber);
+.empty-mini {
+  border: 1px dashed rgba(255, 255, 255, 0.22);
+  border-radius: 0.5rem;
+  color: #aabbd0;
+  padding: 0.75rem;
 }
 
-.badge.missing,
-.badge.failed {
-  background: #fff0ee;
-  color: var(--red);
+.main-panel {
+  min-height: 32rem;
+  overflow: hidden;
 }
 
-.badge.idle {
-  background: #edf1f6;
-  color: #506070;
+.empty-state {
+  align-content: center;
+  display: grid;
+  gap: 0.5rem;
+  justify-items: center;
+  min-height: 28rem;
+  padding: 2rem;
+  text-align: center;
 }
 
 .detail-header {
-  border-bottom: 1px solid var(--line);
-  display: flex;
+  align-items: flex-start;
   justify-content: space-between;
-  gap: 18px;
-  padding: 18px;
-}
-
-.detail-title {
-  display: grid;
-  gap: 8px;
-}
-
-.detail-title h2 {
-  font-size: 22px;
-  overflow-wrap: anywhere;
-}
-
-.detail-actions {
-  align-items: start;
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-  justify-content: end;
 }
 
+.detail-actions,
 .status-strip {
   display: flex;
   flex-wrap: wrap;
-  gap: 8px;
+  gap: 0.5rem;
 }
 
 .detail-body {
   display: grid;
-  gap: 18px;
-  padding: 18px;
-}
-
-.grid-two {
-  display: grid;
-  gap: 16px;
-  grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
-}
-
-.subpanel {
-  border: 1px solid var(--line);
-  border-radius: 8px;
-  padding: 14px;
-}
-
-.subpanel > h3 {
-  margin-bottom: 12px;
-}
-
-.subpanel-head {
-  align-items: center;
-  display: flex;
-  gap: 10px;
-  justify-content: space-between;
-  margin-bottom: 12px;
-}
-
-.subpanel-head h3 {
-  margin: 0;
+  gap: 1rem;
 }
 
 .guide-hero {
-  align-items: stretch;
-  background: #ffffff;
-  border: 1px solid var(--line);
-  border-radius: 8px;
+  background: linear-gradient(135deg, #f7fbff, #f6f8fb);
+  border: 1px solid var(--mh-line);
+  border-radius: 0.75rem;
   display: grid;
-  gap: 18px;
-  grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
-  padding: 16px;
+  gap: 1rem;
+  grid-template-columns: minmax(16rem, 0.8fr) minmax(0, 1.2fr);
+  padding: 1rem;
 }
 
 .guide-hero h3 {
-  font-size: 24px;
-  margin: 4px 0;
+  font-size: 1.35rem;
+  margin: 0.2rem 0;
   overflow-wrap: anywhere;
 }
 
 .guide-hero p {
-  color: var(--muted);
-  font-size: 13px;
+  color: var(--mh-muted);
+  margin: 0;
   overflow-wrap: anywhere;
 }
 
-.eyebrow {
-  color: var(--teal);
-  font-size: 11px;
-  font-weight: 800;
-  letter-spacing: 0.08em;
-  text-transform: uppercase;
-}
-
 .summary-grid {
   display: grid;
-  gap: 10px;
-  grid-template-columns: repeat(5, minmax(92px, 1fr));
+  gap: 0.75rem;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
 }
 
-.metric-card {
-  background: #f8fafc;
-  border: 1px solid var(--line);
-  border-radius: 8px;
+.metric-card.compact {
+  background: #ffffff;
+  border: 1px solid var(--mh-line);
+  border-radius: 0.65rem;
   display: grid;
-  gap: 7px;
-  min-height: 78px;
-  padding: 12px;
+  gap: 0.35rem;
+  min-height: 4.7rem;
+  padding: 0.8rem;
 }
 
-.metric-card span {
-  color: var(--muted);
-  font-size: 12px;
+.metric-card.compact span {
+  color: var(--mh-muted);
+  font-size: 0.78rem;
 }
 
-.metric-card strong {
-  font-size: 14px;
-  overflow-wrap: anywhere;
+.metric-card.compact strong {
+  color: var(--mh-ink);
+  font-size: 0.95rem;
 }
 
-.empty-guide {
-  align-items: center;
-  background: #f8fafc;
-  border: 1px dashed var(--line-strong);
-  border-radius: 8px;
-  display: grid;
-  gap: 6px;
-  min-height: 190px;
-  padding: 20px;
-  text-align: center;
-}
-
-.empty-guide h4,
-.record-card h4 {
-  font-size: 15px;
-  letter-spacing: 0;
-  margin: 0;
-}
-
-.empty-guide p {
-  color: var(--muted);
-  font-size: 13px;
-  margin: 0;
+.subpanel {
+  height: 100%;
 }
 
 .record-steps {
   display: grid;
-  gap: 12px;
+  gap: 0.85rem;
 }
 
 .record-card {
-  background: #fff;
-  border: 1px solid var(--line);
-  border-radius: 8px;
+  border: 1px solid var(--mh-line);
+  border-radius: 0.75rem;
   display: grid;
-  gap: 12px;
-  grid-template-columns: 34px minmax(0, 1fr);
-  padding: 12px;
+  gap: 0.75rem;
+  grid-template-columns: 2rem minmax(0, 1fr);
+  padding: 0.85rem;
 }
 
 .record-card.ok {
-  border-color: #a8d9bd;
+  border-color: rgba(47, 179, 68, 0.45);
 }
 
 .record-card.warn {
-  border-color: #f0d49e;
+  border-color: rgba(245, 159, 0, 0.45);
 }
 
 .record-card.missing,
 .record-card.failed {
-  border-color: #efb1ac;
+  border-color: rgba(214, 57, 57, 0.45);
 }
 
 .record-step span {
   align-items: center;
-  background: #ecf2f8;
+  background: #eef4fb;
   border-radius: 999px;
-  color: #38516f;
+  color: var(--mh-blue);
   display: flex;
-  font-size: 12px;
-  font-weight: 800;
-  height: 28px;
+  font-size: 0.75rem;
+  font-weight: 700;
+  height: 1.75rem;
   justify-content: center;
-  width: 28px;
+  width: 1.75rem;
 }
 
 .record-card-body {
   display: grid;
-  gap: 10px;
+  gap: 0.7rem;
   min-width: 0;
 }
 
 .record-card-title {
-  align-items: start;
+  align-items: flex-start;
   display: flex;
-  gap: 10px;
+  gap: 0.75rem;
   justify-content: space-between;
 }
 
-.record-card-title p {
-  color: var(--muted);
-  font-size: 12px;
-  margin-top: 4px;
-  overflow-wrap: anywhere;
-}
-
-.dns-value,
-.dns-current {
-  display: grid;
-  gap: 7px;
+.record-card-title h4 {
+  font-size: 0.98rem;
+  margin: 0;
 }
 
+.record-card-title p,
 .dns-value > span,
-.dns-current > span {
-  color: var(--muted);
-  font-size: 12px;
-}
-
-.dns-value .small-button {
-  justify-self: start;
-}
-
-.inline-warnings {
-  display: grid;
-  gap: 6px;
+.dns-current > span,
+.live-row span,
+.smtp-row span,
+.copy-row > span {
+  color: var(--mh-muted);
+  font-size: 0.78rem;
   margin: 0;
-  padding: 0;
-}
-
-.inline-warnings li {
-  background: #fff8ea;
-  border: 1px solid #f1d49d;
-  border-radius: 6px;
-  color: #684000;
-  font-size: 12px;
-  list-style: none;
-  padding: 8px 9px;
-}
-
-.record-table-wrap {
-  overflow-x: auto;
-}
-
-table {
-  border-collapse: collapse;
-  width: 100%;
-}
-
-th,
-td {
-  border-bottom: 1px solid var(--line);
-  font-size: 13px;
-  padding: 10px 8px;
-  text-align: left;
-  vertical-align: top;
 }
 
-th {
-  color: var(--muted);
-  font-weight: 600;
+.dns-value,
+.dns-current,
+.live-list,
+.smtp-grid,
+.event-list {
+  display: grid;
+  gap: 0.65rem;
 }
 
-code,
-.mono {
-  font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
-  font-size: 12px;
+.dns-value .btn {
+  justify-self: start;
 }
 
 code {
-  background: #f3f6fa;
-  border: 1px solid #e0e7f1;
-  border-radius: 6px;
+  background: #f4f7fb;
+  border: 1px solid #e1e8f0;
+  border-radius: 0.4rem;
+  color: #24334a;
   display: block;
-  max-width: 780px;
+  font-size: 0.78rem;
   overflow-wrap: anywhere;
-  padding: 7px;
+  padding: 0.45rem 0.55rem;
   white-space: normal;
 }
 
-.warning-list {
-  display: grid;
-  gap: 8px;
+.api-box pre {
+  background: #111827;
+  border-radius: 0.65rem;
+  color: #e5eefb;
   margin: 0;
-  padding: 0;
-}
-
-.warning-list li {
-  background: #fff8ea;
-  border: 1px solid #f1d49d;
-  border-radius: 6px;
-  color: #684000;
-  list-style: none;
-  padding: 9px 10px;
-}
-
-.live-list {
-  display: grid;
-  gap: 10px;
-}
-
-.live-row {
-  display: grid;
-  gap: 5px;
+  overflow-x: auto;
+  padding: 0.9rem;
 }
 
-.live-row span {
-  color: var(--muted);
-  font-size: 12px;
+.api-box pre code {
+  background: transparent;
+  border: 0;
+  color: inherit;
+  padding: 0;
 }
 
-.send-form {
+.inline-warnings,
+.warning-list {
   display: grid;
-  gap: 10px;
+  gap: 0.5rem;
+  margin: 0;
+  padding: 0;
 }
 
-.button-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
+.inline-warnings li,
+.warning-list li {
+  background: #fff8e6;
+  border: 1px solid #f5d88d;
+  border-radius: 0.5rem;
+  color: #6b4500;
+  list-style: none;
+  padding: 0.55rem 0.65rem;
 }
 
-.credential-copy {
-  display: grid;
-  gap: 8px;
+.record-table-wrap {
+  overflow-x: auto;
 }
 
 .copy-row {
   align-items: start;
   display: grid;
-  gap: 7px;
-  grid-template-columns: minmax(72px, auto) minmax(0, 1fr) auto;
+  gap: 0.55rem;
+  grid-template-columns: minmax(4.8rem, auto) minmax(0, 1fr) auto;
 }
 
 .copy-row.inline {
   grid-template-columns: minmax(0, 1fr) auto;
 }
 
-.copy-row > span {
-  color: var(--muted);
-  font-size: 12px;
-  padding-top: 7px;
-}
-
 .copy-row > p {
   grid-column: 2 / -1;
-  padding-top: 7px;
+  margin: 0;
 }
 
+.credential-list,
 .mini-list {
   display: grid;
-  gap: 8px;
+  gap: 0.75rem;
 }
 
+.credential-row .card-body,
 .mini-row {
-  align-items: start;
-  background: #fbfcfe;
-  border: 1px solid var(--line);
-  border-radius: 8px;
+  align-items: flex-start;
   display: flex;
-  gap: 8px;
+  gap: 0.75rem;
   justify-content: space-between;
-  padding: 9px;
+}
+
+.mini-row {
+  border: 1px solid var(--mh-line);
+  border-radius: 0.65rem;
+  padding: 0.75rem;
 }
 
 .mini-row > div:first-child {
   display: grid;
-  gap: 3px;
+  gap: 0.2rem;
   min-width: 0;
 }
 
@@ -650,91 +375,54 @@ code {
   overflow-wrap: anywhere;
 }
 
-.small-danger {
-  min-height: 30px;
-  padding: 0 9px;
-}
-
-body.busy button {
-  cursor: wait;
-}
-
-.notice {
-  background: #fff5f4;
-  border: 1px solid #f0b6b1;
-  border-radius: 8px;
-  color: var(--red);
-  margin: 0 auto 16px;
-  max-width: 1480px;
-  padding: 10px 12px;
-}
-
-.hidden {
-  display: none;
-}
-
-.empty-state {
-  align-content: center;
-  display: grid;
-  gap: 8px;
-  justify-items: center;
-  min-height: 720px;
-  padding: 20px;
-  text-align: center;
-}
-
-.empty-state p {
-  color: var(--muted);
-}
-
-.api-box {
+.event-row {
+  border-bottom: 1px solid var(--mh-line);
   display: grid;
-  gap: 10px;
-}
-
-.api-box pre {
-  background: #101820;
-  border-radius: 8px;
-  color: #e9f2ff;
-  margin: 0;
-  overflow-x: auto;
-  padding: 12px;
+  gap: 0.25rem;
+  padding: 0.55rem 0;
 }
 
-.event-list {
-  display: grid;
-  gap: 8px;
+.event-row:last-child {
+  border-bottom: 0;
 }
 
-.smtp-grid {
-  display: grid;
-  gap: 10px;
+.item-line {
+  align-items: center;
+  display: flex;
+  gap: 0.75rem;
+  justify-content: space-between;
 }
 
-.smtp-row {
+.toast-host {
+  bottom: 1rem;
   display: grid;
-  gap: 6px;
+  gap: 0.5rem;
+  position: fixed;
+  right: 1rem;
+  z-index: 1080;
 }
 
-.smtp-row span {
-  color: var(--muted);
-  font-size: 12px;
+.toast {
+  background: #ffffff;
+  border: 1px solid var(--mh-line);
+  border-left: 4px solid var(--mh-blue);
+  border-radius: 0.6rem;
+  box-shadow: 0 1rem 2rem rgba(22, 34, 51, 0.16);
+  color: var(--mh-ink);
+  min-width: 14rem;
+  padding: 0.75rem 0.9rem;
 }
 
-.event-row {
-  border-bottom: 1px solid var(--line);
-  display: grid;
-  gap: 4px;
-  padding: 8px 0;
+.toast.error {
+  border-left-color: var(--mh-red);
 }
 
-.event-row:last-child {
-  border-bottom: 0;
+body.busy button {
+  cursor: wait;
 }
 
-@media (max-width: 1080px) {
-  .workspace,
-  .grid-two,
+@media (max-width: 1200px) {
+  .summary-grid,
   .guide-hero {
     grid-template-columns: 1fr;
   }
@@ -742,41 +430,27 @@ body.busy button {
   .summary-grid {
     grid-template-columns: repeat(2, minmax(0, 1fr));
   }
-
-  .main-panel {
-    min-height: 520px;
-  }
 }
 
-@media (max-width: 640px) {
-  .app-shell {
-    padding: 12px;
-  }
-
-  .topbar,
-  .detail-header {
+@media (max-width: 768px) {
+  .topbar .container-xl,
+  .detail-header,
+  .credential-row .card-body,
+  .mini-row {
     align-items: stretch;
     flex-direction: column;
   }
 
-  .detail-actions {
-    justify-content: start;
-  }
-
-  .form-grid {
-    grid-template-columns: 1fr;
-  }
-
   .summary-grid {
     grid-template-columns: 1fr;
   }
 
+  .copy-row,
   .record-card {
     grid-template-columns: 1fr;
   }
 
-  .record-card-title {
-    align-items: stretch;
-    flex-direction: column;
+  .copy-row > p {
+    grid-column: auto;
   }
 }