279 lines
11 KiB
JavaScript
279 lines
11 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
||
* page-settings.js — 爬蟲管理中心
|
||
* 從原 settings.html L1230-L1648 抽出
|
||
* 邏輯與原版一致
|
||
* ═══════════════════════════════════════════════════════════ */
|
||
|
||
let categoryModal;
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
categoryModal = new bootstrap.Modal(document.getElementById('categoryModal'));
|
||
loadCrawlers();
|
||
|
||
// 顯示搜尋框(如果有分類資料)
|
||
const grid = document.getElementById('categories-grid');
|
||
const searchContainer = document.getElementById('search-container');
|
||
if (grid && grid.children.length > 0) {
|
||
searchContainer.style.display = 'block';
|
||
}
|
||
});
|
||
|
||
// ───────── Toast / Loading ─────────
|
||
function showToast(message, type = 'success') {
|
||
const container = document.getElementById('toast-container');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast-notification ${type}`;
|
||
const icon = type === 'success' ? 'check-circle' : 'exclamation-circle';
|
||
toast.innerHTML = `
|
||
<div class="toast-icon"><i class="fas fa-${icon}"></i></div>
|
||
<div class="toast-content"><div class="toast-message">${message}</div></div>
|
||
`;
|
||
container.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.animation = 'slideIn 0.25s ease-out reverse';
|
||
setTimeout(() => toast.remove(), 250);
|
||
}, 4000);
|
||
}
|
||
|
||
function showLoading() { document.getElementById('loading-overlay').classList.add('active'); }
|
||
function hideLoading() { document.getElementById('loading-overlay').classList.remove('active'); }
|
||
function getCSRFToken() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); }
|
||
|
||
// ───────── Crawler ─────────
|
||
async function loadCrawlers() {
|
||
try {
|
||
const r = await fetch('/api/crawlers');
|
||
const result = await r.json();
|
||
if (result.status === 'success') {
|
||
renderCrawlers(result.data);
|
||
updateStats(result.data);
|
||
} else {
|
||
showToast('載入失敗: ' + result.message, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('載入爬蟲配置時發生錯誤', 'error');
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
function renderCrawlers(crawlers) {
|
||
const container = document.getElementById('crawlers-container');
|
||
container.innerHTML = '';
|
||
for (const [key, info] of Object.entries(crawlers)) {
|
||
container.appendChild(createCrawlerCard(key, info));
|
||
}
|
||
}
|
||
|
||
function createCrawlerCard(key, info) {
|
||
const card = document.createElement('div');
|
||
card.className = `crawler-card ${info.enabled ? 'active' : 'paused'}`;
|
||
const statusClass = info.enabled ? 'active' : 'paused';
|
||
const statusText = info.enabled ? '運行中' : '已暫停';
|
||
|
||
card.innerHTML = `
|
||
<div class="crawler-header">
|
||
<div class="crawler-title-group">
|
||
<div class="crawler-title"><i class="fas fa-robot"></i>${info.name}</div>
|
||
<span class="status-badge ${statusClass}"><i class="fas fa-circle"></i>${statusText}</span>
|
||
</div>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" ${info.enabled ? 'checked' : ''} onchange="toggleCrawler('${key}', this.checked)">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
<div class="crawler-body">
|
||
<div class="crawler-info">
|
||
<div class="info-item"><i class="fas fa-info-circle"></i><span>${info.description || 'N/A'}</span></div>
|
||
<div class="info-item"><i class="fas fa-clock"></i><span>每 ${info.schedule_hours || 'N/A'} 小時執行</span></div>
|
||
${info.lpn_code ? `<div class="info-item"><i class="fas fa-barcode"></i><span>活動代碼:${info.lpn_code}</span></div>` : ''}
|
||
${info.last_active_date ? `<div class="info-item"><i class="fas fa-calendar-check"></i><span>最後活動:${info.last_active_date}</span></div>` : ''}
|
||
</div>
|
||
${!info.enabled && info.pause_reason ? `
|
||
<div class="pause-reason">
|
||
<strong><i class="fas fa-pause-circle me-2"></i>暫停原因</strong>
|
||
${info.pause_reason}
|
||
${info.notes ? `<br><small><i class="fas fa-sticky-note me-1"></i>${info.notes}</small>` : ''}
|
||
</div>` : ''}
|
||
${info.enabled ? `
|
||
<div class="crawler-controls">
|
||
<button class="btn-custom btn-schedule" onclick="changeSchedule('${key}', ${info.schedule_hours})">
|
||
<i class="fas fa-edit"></i>修改頻率
|
||
</button>
|
||
</div>` : ''}
|
||
</div>
|
||
`;
|
||
return card;
|
||
}
|
||
|
||
function updateStats(crawlers) {
|
||
const total = Object.keys(crawlers).length;
|
||
const enabled = Object.values(crawlers).filter(c => c.enabled).length;
|
||
document.getElementById('enabled-count').textContent = enabled;
|
||
document.getElementById('paused-count').textContent = total - enabled;
|
||
document.getElementById('total-count').textContent = total;
|
||
}
|
||
|
||
async function toggleCrawler(key, enabled) {
|
||
let reason = '';
|
||
if (!enabled) reason = prompt('請輸入停用原因(選填):') || '手動停用';
|
||
showLoading();
|
||
try {
|
||
const r = await fetch(`/api/crawlers/${key}/toggle`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled, reason })
|
||
});
|
||
const result = await r.json();
|
||
if (result.status === 'success') {
|
||
showToast(result.message, 'success');
|
||
} else {
|
||
showToast('操作失敗: ' + result.message, 'error');
|
||
}
|
||
await loadCrawlers();
|
||
} catch (e) {
|
||
showToast('操作時發生錯誤', 'error');
|
||
console.error(e);
|
||
await loadCrawlers();
|
||
} finally {
|
||
hideLoading();
|
||
}
|
||
}
|
||
|
||
async function changeSchedule(key, currentHours) {
|
||
const newHours = prompt(`請輸入新的執行頻率(小時,範圍:1-24)\n\n目前設定:每 ${currentHours} 小時`, currentHours);
|
||
if (newHours === null || newHours === currentHours.toString()) return;
|
||
const hours = parseInt(newHours);
|
||
if (isNaN(hours) || hours < 1 || hours > 24) { showToast('請輸入有效的小時數(1-24)', 'error'); return; }
|
||
showLoading();
|
||
try {
|
||
const r = await fetch(`/api/crawlers/${key}/schedule`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ schedule_hours: hours })
|
||
});
|
||
const result = await r.json();
|
||
if (result.status === 'success') {
|
||
showToast(result.message + '(需重啟排程器生效)', 'success');
|
||
await loadCrawlers();
|
||
} else {
|
||
showToast('更新失敗: ' + result.message, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('更新時發生錯誤', 'error');
|
||
console.error(e);
|
||
} finally {
|
||
hideLoading();
|
||
}
|
||
}
|
||
|
||
async function refreshData() {
|
||
const btn = event.target.closest('.refresh-btn');
|
||
btn.classList.add('spinning');
|
||
btn.disabled = true;
|
||
await loadCrawlers();
|
||
setTimeout(() => {
|
||
btn.classList.remove('spinning');
|
||
btn.disabled = false;
|
||
showToast('已刷新爬蟲狀態', 'success');
|
||
}, 600);
|
||
}
|
||
|
||
// ───────── Category ─────────
|
||
function prepareAddModal() {
|
||
document.getElementById('modalTitle').innerText = '新增分類';
|
||
document.getElementById('categoryId').value = '';
|
||
document.getElementById('categoryName').value = '';
|
||
document.getElementById('categoryUrl').value = '';
|
||
categoryModal.show();
|
||
}
|
||
|
||
function prepareEditModal(id, name, url) {
|
||
document.getElementById('modalTitle').innerText = '編輯分類';
|
||
document.getElementById('categoryId').value = id;
|
||
document.getElementById('categoryName').value = name;
|
||
document.getElementById('categoryUrl').value = url;
|
||
categoryModal.show();
|
||
}
|
||
|
||
function saveCategory() {
|
||
const id = document.getElementById('categoryId').value;
|
||
const name = document.getElementById('categoryName').value;
|
||
const url = document.getElementById('categoryUrl').value;
|
||
const method = id ? 'PUT' : 'POST';
|
||
const endpoint = id ? `/api/categories/${id}` : '/api/categories';
|
||
const formData = new FormData();
|
||
formData.append('name', name);
|
||
formData.append('url', url);
|
||
showLoading();
|
||
fetch(endpoint, { method, headers: { 'X-CSRFToken': getCSRFToken() }, body: formData })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.status === 'success') {
|
||
categoryModal.hide();
|
||
showToast(id ? '分類已更新' : '分類已新增', 'success');
|
||
setTimeout(() => location.reload(), 800);
|
||
} else {
|
||
showToast('操作失敗: ' + d.message, 'error');
|
||
}
|
||
})
|
||
.catch(e => { console.error(e); showToast('發生錯誤,請稍後再試', 'error'); })
|
||
.finally(hideLoading);
|
||
}
|
||
|
||
function deleteCategory(id) {
|
||
if (!confirm('確定要刪除此分類嗎?此操作無法復原。')) return;
|
||
showLoading();
|
||
fetch(`/api/categories/${id}`, { method: 'DELETE', headers: { 'X-CSRFToken': getCSRFToken() } })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.status === 'success') {
|
||
showToast('分類已刪除', 'success');
|
||
setTimeout(() => location.reload(), 800);
|
||
} else {
|
||
showToast('刪除失敗: ' + d.message, 'error');
|
||
}
|
||
})
|
||
.catch(e => { console.error(e); showToast('發生錯誤,請稍後再試', 'error'); })
|
||
.finally(hideLoading);
|
||
}
|
||
|
||
function testUrl(url, btn) {
|
||
const orig = btn.innerHTML;
|
||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||
btn.disabled = true;
|
||
fetch('/api/test_url', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCSRFToken() },
|
||
body: JSON.stringify({ url })
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => showToast(d.message, d.status === 'success' ? 'success' : 'error'))
|
||
.catch(e => { console.error(e); showToast('測試請求失敗', 'error'); })
|
||
.finally(() => { btn.innerHTML = orig; btn.disabled = false; });
|
||
}
|
||
|
||
// ───────── Filter ─────────
|
||
function filterCategories() {
|
||
const input = document.getElementById('category-search');
|
||
const box = document.getElementById('search-box');
|
||
const term = input.value.toLowerCase().trim();
|
||
const cards = document.querySelectorAll('.category-card');
|
||
const noResults = document.getElementById('no-results');
|
||
let visible = 0;
|
||
box.classList.toggle('has-text', !!term);
|
||
cards.forEach(card => {
|
||
const m = card.dataset.categoryName.includes(term) || card.dataset.categoryUrl.includes(term);
|
||
card.style.display = m ? '' : 'none';
|
||
if (m) visible++;
|
||
});
|
||
noResults.style.display = (visible === 0 && term) ? 'block' : 'none';
|
||
}
|
||
|
||
function clearSearch() {
|
||
const input = document.getElementById('category-search');
|
||
document.getElementById('search-box').classList.remove('has-text');
|
||
input.value = '';
|
||
filterCategories();
|
||
input.focus();
|
||
}
|