码桶
发现社区成员的开源项目
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 項目設置 → 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);
});