码桶

发现社区成员的开源项目

admin.js19.1 KB
/**
 * admin.js - 後台管理邏輯
 */

let isLoggedIn = false;
let isApiMode = false;
let editingId = null;

// 顯示提示
function showToast(msg, type = 'info') {
  const toast = document.createElement('div');
  toast.className = 'toast ' + type;
  toast.textContent = msg;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 3000);
}

// 登入
function showLogin() {
  const overlay = document.getElementById('loginOverlay');
  overlay.style.display = 'flex';
  document.getElementById('loginPwd').focus();
}

function hideLogin() {
  document.getElementById('loginOverlay').style.display = 'none';
}

// 檢查會話(刷新頁面時自動登入)
async function checkSession() {
  const token = sessionStorage.getItem('admin_token');
  if (!token) return false;
  isApiMode = sessionStorage.getItem('admin_api_mode') === '1';
  isLoggedIn = true;
  hideLogin();
  document.getElementById('mainContent').style.display = '';
  await DataSync.init();
  loadStats();
  loadDomainList();
  initCategoryOptions();
  loadSettings();
  updatePasswordSection();
  return true;
}

// 登入(API 優先,localStorage 兜底)
async function doLogin() {
  const pwd = document.getElementById('loginPwd').value;
  if (!pwd) return;

  const loginBtn = document.getElementById('loginBtn');
  loginBtn.textContent = '驗證中...';
  loginBtn.disabled = true;

  try {
    // 嘗試調用 Vercel API 驗證
    const resp = await fetch('/api/auth', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password: pwd })
    });

    if (!resp.ok) throw new Error('API error');
    const data = await resp.json();

    if (data.success) {
      isApiMode = true;
      sessionStorage.setItem('admin_token', data.token || '1');
      sessionStorage.setItem('admin_api_mode', '1');
      await onLoginSuccess();
      return;
    }

    showToast('密碼錯誤!', 'error');
    resetLoginInput();
  } catch (e) {
    // API 不可用,回退到 localStorage(純靜態部署模式)
    if (DomainDB.verifyPassword(pwd)) {
      isApiMode = false;
      sessionStorage.setItem('admin_token', 'local_' + Date.now());
      sessionStorage.setItem('admin_api_mode', '0');
      await onLoginSuccess();
      return;
    }
    showToast('密碼錯誤!', 'error');
    resetLoginInput();
  } finally {
    loginBtn.textContent = '登入';
    loginBtn.disabled = false;
  }
}

async function onLoginSuccess() {
  isLoggedIn = true;
  hideLogin();
  document.getElementById('mainContent').style.display = '';
  showToast('登入成功!', 'success');
  await DataSync.init();
  loadStats();
  loadDomainList();
  initCategoryOptions();
  loadSettings();
  updatePasswordSection();
}

function resetLoginInput() {
  document.getElementById('loginPwd').value = '';
  document.getElementById('loginPwd').focus();
}

function doLogout() {
  isLoggedIn = false;
  sessionStorage.removeItem('admin_token');
  sessionStorage.removeItem('admin_api_mode');
  document.getElementById('mainContent').style.display = 'none';
  showLogin();
}

// 根據模式更新密碼區域
function updatePasswordSection() {
  const pwdArea = document.getElementById('pwdArea');
  if (!pwdArea) return;

  if (isApiMode) {
    // 隱藏密碼表單(不銷毀,避免影響事件綁定)
    const form = pwdArea.querySelector('#pwdForm');
    if (form) form.style.display = 'none';

    // 插入提示信息(只插一次)
    if (!pwdArea.querySelector('.api-mode-msg')) {
      const msg = document.createElement('div');
      msg.className = 'api-mode-msg';
      msg.style.cssText = 'padding:15px;background:#FFF;border:1px solid #FF99FF;border-radius:5px;';
      msg.innerHTML =
        '<p style="color:#990000;font-weight:bold;margin-bottom:8px;">密碼由 Vercel 環境變量管理</p>' +
        '<p style="font-size:13px;color:#666;">當前為 Vercel 部署模式,管理密碼由環境變量 ' +
        '<code style="background:#FFF0FF;padding:2px 6px;border-radius:3px;">ADMIN_PASSWORD</code> 設定。<br>' +
        '如需修改密碼,請到 Vercel 項目設置 &rarr; Environment Variables 中修改。</p>';
      pwdArea.insertBefore(msg, form || null);
    }
  }
}

// 統計
function loadStats() {
  const stats = DomainDB.getStats();
  document.getElementById('statTotal').textContent = stats.total;
  document.getElementById('statAvailable').textContent = stats.available;
  document.getElementById('statPending').textContent = stats.pending;
  document.getElementById('statSold').textContent = stats.sold;
  document.getElementById('statValue').textContent = '$' + stats.totalValue.toLocaleString('zh-HK');
}

// 分類選項
function initCategoryOptions() {
  const cats = DomainDB.getCategories();
  const select = document.getElementById('formCategory');
  select.innerHTML = '';
  cats.forEach(c => {
    select.innerHTML += `<option value="${c}">${c}</option>`;
  });
}

// 載入域名列表
function loadDomainList() {
  const keyword = document.getElementById('adminSearch').value.trim().toLowerCase();
  const statusFilter = document.getElementById('adminStatusFilter').value;
  let all = DomainDB.getAll();

  if (keyword) {
    all = all.filter(d =>
      d.name.toLowerCase().includes(keyword) ||
      d.category.toLowerCase().includes(keyword)
    );
  }

  if (statusFilter !== 'all') {
    all = all.filter(d => d.status === statusFilter);
  }

  all.sort((a, b) => {
    if (a.category !== b.category) return a.category.localeCompare(b.category, 'zh-Hant');
    return (b.price || 0) - (a.price || 0);
  });

  const tbody = document.getElementById('adminTableBody');
  if (all.length === 0) {
    tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:30px;color:#999;">暫無數據</td></tr>';
    return;
  }

  const statusMap = {
    'available': '<span style="color:#009900;">● 在售</span>',
    'pending': '<span style="color:#ff6600;">● 洽談中</span>',
    'sold': '<span style="color:#cc0000;">● 已售</span>'
  };

  tbody.innerHTML = all.map(d => `
    <tr>
      <td>${d.category}</td>
      <td style="font-family:Arial;font-weight:bold;">${d.name}</td>
      <td style="text-align:right;">$${Number(d.price).toLocaleString('zh-HK')}</td>
      <td>${d.currency || 'HKD'}</td>
      <td>${statusMap[d.status] || d.status}</td>
      <td>${d.contact || '-'}</td>
      <td style="white-space:nowrap;">
        <button class="btn btn-warning btn-sm" onclick="editDomain(${d.id})">編輯</button>
        <button class="btn btn-danger btn-sm" onclick="deleteDomain(${d.id})">刪除</button>
      </td>
    </tr>
  `).join('');
}

// 新增/編輯
function editDomain(id) {
  const all = DomainDB.getAll();
  const d = all.find(x => x.id === id);
  if (!d) return;

  editingId = id;
  document.getElementById('formCategory').value = d.category;
  // 如果分類不在選項中,手動加
  if (![...document.getElementById('formCategory').options].some(o => o.value === d.category)) {
    const opt = document.createElement('option');
    opt.value = d.category;
    opt.textContent = d.category;
    document.getElementById('formCategory').appendChild(opt);
    document.getElementById('formCategory').value = d.category;
  }
  document.getElementById('formName').value = d.name;
  document.getElementById('formPrice').value = d.price;
  document.getElementById('formCurrency').value = d.currency || 'HKD';
  document.getElementById('formContact').value = d.contact || '';
  document.getElementById('formStatus').value = d.status || 'available';

  document.getElementById('formTitle').textContent = '編輯域名';
  document.getElementById('submitBtn').textContent = '更新';
  document.getElementById('cancelBtn').style.display = '';

  window.scrollTo({ top: 0, behavior: 'smooth' });
}

function cancelEdit() {
  editingId = null;
  resetForm();
  document.getElementById('formTitle').textContent = '新增域名';
  document.getElementById('submitBtn').textContent = '新增';
  document.getElementById('cancelBtn').style.display = 'none';
}

function resetForm() {
  document.getElementById('domainForm').reset();
  document.getElementById('formCurrency').value = 'HKD';
  document.getElementById('formStatus').value = 'available';
}

function submitDomain(e) {
  e.preventDefault();
  const category = document.getElementById('formCategory').value.trim();
  const name = document.getElementById('formName').value.trim();
  const price = Number(document.getElementById('formPrice').value);
  const currency = document.getElementById('formCurrency').value;
  const contact = document.getElementById('formContact').value.trim();
  const status = document.getElementById('formStatus').value;

  if (!category || !name || !price) {
    showToast('請填寫分類、域名和價格', 'error');
    return;
  }

  // 檢查重複(編輯時排除自身)
  const all = DomainDB.getAll();
  const dup = all.find(d => d.name.toLowerCase() === name.toLowerCase() && d.id !== editingId);
  if (dup) {
    showToast('域名已存在!', 'error');
    return;
  }

  const data = { category, name, price, currency, contact, status };

  if (editingId) {
    DomainDB.update(editingId, data);
    showToast('更新成功!', 'success');
  } else {
    DomainDB.add(data);
    showToast('新增成功!', 'success');
  }

  cancelEdit();
  loadStats();
  loadDomainList();
  initCategoryOptions();
}

function deleteDomain(id) {
  const all = DomainDB.getAll();
  const d = all.find(x => x.id === id);
  if (!d) return;

  if (confirm(`確定要刪除「${d.name}」嗎?`)) {
    DomainDB.delete(id);
    showToast('已刪除', 'success');
    loadStats();
    loadDomainList();
    if (editingId === id) cancelEdit();
  }
}

// 批量匯入
function batchImport() {
  const text = document.getElementById('batchInput').value.trim();
  if (!text) {
    showToast('請輸入數據', 'error');
    return;
  }

  try {
    const lines = text.split('\n').filter(l => l.trim());
    let count = 0;
    lines.forEach(line => {
      // 格式: 分類,域名,價格 或 分類 域名 價格
      const parts = line.split(/[,,\t]/).map(p => p.trim());
      if (parts.length >= 3) {
        const price = Number(parts[2].replace(/[$,,]/g, ''));
        if (parts[0] && parts[1] && price > 0) {
          DomainDB.add({
            category: parts[0],
            name: parts[1],
            price: price,
            currency: 'HKD',
            contact: '',
            status: 'available'
          });
          count++;
        }
      }
    });

    if (count > 0) {
      showToast(`成功匯入 ${count} 個域名`, 'success');
      document.getElementById('batchInput').value = '';
      document.getElementById('batchArea').style.display = 'none';
      loadStats();
      loadDomainList();
      initCategoryOptions();
    } else {
      showToast('未能解析任何數據,請檢查格式', 'error');
    }
  } catch (e) {
    showToast('匯入失敗:' + e.message, 'error');
  }
}

// JSON 導出
function exportData() {
  const json = DomainDB.export();
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'domains_' + new Date().toISOString().slice(0, 10) + '.json';
  a.click();
  URL.revokeObjectURL(url);
  showToast('已導出 JSON 檔案', 'success');
}

// JSON 匯入
function importData(event) {
  const file = event.target.files[0];
  if (!file) return;

  const reader = new FileReader();
  reader.onload = function(e) {
    const result = DomainDB.import(e.target.result, 'merge');
    if (result.success) {
      showToast(`成功匯入 ${result.count} 條記錄`, 'success');
      loadStats();
      loadDomainList();
      initCategoryOptions();
    } else {
      showToast('匯入失敗:' + result.error, 'error');
    }
  };
  reader.readAsText(file);
  event.target.value = '';
}

// 修改密碼(僅靜態模式可用)
function changePassword() {
  if (isApiMode) {
    showToast('Vercel 模式下密碼由環境變量管理,請到 Vercel 後台修改', 'info');
    return;
  }

  const oldPwd = document.getElementById('oldPwd').value;
  const newPwd = document.getElementById('newPwd').value;
  const confirmPwd = document.getElementById('confirmPwd').value;

  if (!DomainDB.verifyPassword(oldPwd)) {
    showToast('原密碼錯誤', 'error');
    return;
  }
  if (newPwd.length < 4) {
    showToast('新密碼至少4個字符', 'error');
    return;
  }
  if (newPwd !== confirmPwd) {
    showToast('兩次密碼不一致', 'error');
    return;
  }

  DomainDB.setPassword(newPwd);
  showToast('密碼已修改', 'success');
  document.getElementById('pwdForm').reset();
  document.getElementById('pwdArea').style.display = 'none';
}

// 清空數據
function clearAllData() {
  if (confirm('⚠️ 確定要清空所有域名數據嗎?此操作不可恢復!\n\n建議先導出備份。')) {
    if (confirm('再次確認:真的要清空全部數據嗎?')) {
      DomainDB.clearAll();
      showToast('已清空所有數據', 'success');
      loadStats();
      loadDomainList();
      initCategoryOptions();
    }
  }
}

// ========= 網站設置 =========

// 載入設置到表單
function loadSettings() {
  const cfg = SiteConfig.get();
  document.getElementById('setTitleEn').value = cfg.titleEn || '';
  document.getElementById('setTitleZh').value = cfg.titleZh || '';
  document.getElementById('setIntroText').value = cfg.introText || '';
  document.getElementById('setF1Title').value = cfg.feature1Title || '';
  document.getElementById('setF1Desc').value = cfg.feature1Desc || '';
  document.getElementById('setF2Title').value = cfg.feature2Title || '';
  document.getElementById('setF2Desc').value = cfg.feature2Desc || '';
  document.getElementById('setF3Title').value = cfg.feature3Title || '';
  document.getElementById('setF3Desc').value = cfg.feature3Desc || '';
  document.getElementById('setContactPerson').value = cfg.contactPerson || '';
  document.getElementById('setContactPerson2').value = cfg.contactPerson2 || '';
  document.getElementById('setContactPhone').value = cfg.contactPhone || '';
  document.getElementById('setWhatsappNumber').value = cfg.whatsappNumber || '';
  document.getElementById('setEmail1').value = cfg.email1 || '';
  document.getElementById('setEmail2').value = cfg.email2 || '';
  document.getElementById('setCurrency').value = cfg.currency || 'HKD';
  document.getElementById('setContactTitleEn').value = cfg.contactTitleEn || '';
  document.getElementById('setContactTitleZh').value = cfg.contactTitleZh || '';
  document.getElementById('setContactIntro').value = cfg.contactIntro || '';
  document.getElementById('setWechatId').value = cfg.wechatId || '';
  document.getElementById('setTelegramId').value = cfg.telegramId || '';
  document.getElementById('setAddress').value = cfg.address || '';
  document.getElementById('setBusinessHours').value = cfg.businessHours || '';
}

// 儲存設置
function saveSettings(e) {
  e.preventDefault();
  const config = {
    titleEn: document.getElementById('setTitleEn').value.trim(),
    titleZh: document.getElementById('setTitleZh').value.trim(),
    introText: document.getElementById('setIntroText').value.trim(),
    feature1Title: document.getElementById('setF1Title').value.trim(),
    feature1Desc: document.getElementById('setF1Desc').value.trim(),
    feature2Title: document.getElementById('setF2Title').value.trim(),
    feature2Desc: document.getElementById('setF2Desc').value.trim(),
    feature3Title: document.getElementById('setF3Title').value.trim(),
    feature3Desc: document.getElementById('setF3Desc').value.trim(),
    contactPerson: document.getElementById('setContactPerson').value.trim(),
    contactPerson2: document.getElementById('setContactPerson2').value.trim(),
    contactPhone: document.getElementById('setContactPhone').value.trim(),
    whatsappNumber: document.getElementById('setWhatsappNumber').value.trim(),
    email1: document.getElementById('setEmail1').value.trim(),
    email2: document.getElementById('setEmail2').value.trim(),
    currency: document.getElementById('setCurrency').value,
    contactTitleEn: document.getElementById('setContactTitleEn').value.trim(),
    contactTitleZh: document.getElementById('setContactTitleZh').value.trim(),
    contactIntro: document.getElementById('setContactIntro').value.trim(),
    wechatId: document.getElementById('setWechatId').value.trim(),
    telegramId: document.getElementById('setTelegramId').value.trim(),
    address: document.getElementById('setAddress').value.trim(),
    businessHours: document.getElementById('setBusinessHours').value.trim()
  };
  SiteConfig.save(config);
  showToast('網站設置已儲存!前台刷新後生效。', 'success');
}

// 恢復預設設置
function resetSettings() {
  if (confirm('確定要恢復所有設置為預設值嗎?')) {
    SiteConfig.reset();
    loadSettings();
    showToast('已恢復預設設置', 'success');
  }
}

// 頁面初始化
document.addEventListener('DOMContentLoaded', async function() {
  // 安全綁定事件(元素不存在時跳過,不會中斷後續綁定)
  function bind(id, event, handler) {
    const el = document.getElementById(id);
    if (el) el.addEventListener(event, handler);
    else console.warn('Element not found:', id);
  }

  // 先檢查會話,已登入則跳過登入頁
  const hadSession = await checkSession();
  if (!hadSession) {
    showLogin();
  }

  bind('loginBtn', 'click', doLogin);
  bind('loginPwd', 'keypress', function(e) {
    if (e.key === 'Enter') doLogin();
  });

  bind('domainForm', 'submit', submitDomain);
  bind('cancelBtn', 'click', cancelEdit);

  bind('adminSearch', 'input', loadDomainList);
  bind('adminStatusFilter', 'change', loadDomainList);

  bind('batchBtn', 'click', function() {
    const area = document.getElementById('batchArea');
    area.style.display = area.style.display === 'none' ? '' : 'none';
  });
  bind('batchSubmit', 'click', batchImport);

  bind('pwdBtn', 'click', function() {
    const area = document.getElementById('pwdArea');
    area.style.display = area.style.display === 'none' ? '' : 'none';
  });
  bind('pwdForm', 'submit', function(e) {
    e.preventDefault();
    changePassword();
  });

  bind('exportBtn', 'click', exportData);
  bind('importFile', 'change', importData);
  bind('clearBtn', 'click', clearAllData);
  bind('logoutBtn', 'click', doLogout);
  bind('viewSiteBtn', 'click', function() {
    window.open('index.html', '_blank');
  });

  // 網站設置
  bind('settingsBtn', 'click', function() {
    const area = document.getElementById('settingsArea');
    area.style.display = area.style.display === 'none' ? '' : 'none';
    if (area.style.display !== 'none') {
      loadSettings();
    }
  });
  bind('settingsForm', 'submit', saveSettings);
  bind('resetSettingsBtn', 'click', resetSettings);
});