'use strict';
(function () {
const state = {
shops: [],
folderNameByShopId: new Map(),
statusByShopId: new Map(),
};
let pollTimer = null;
let crawlRoot = '';
let initialized = false;
function showToast(msg, type) {
const wrap = document.getElementById('toast-wrap');
const el = document.createElement('div');
el.className = 'toast toast-' + (type || 'success');
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s'; }, 2400);
setTimeout(() => { if (el.parentNode) wrap.removeChild(el); }, 2800);
}
function jsonFetch(url, opts) {
return fetch(url, opts).then((r) =>
r.json().then((data) => {
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
})
);
}
function renderShopTable() {
const body = document.getElementById('shopTableBody');
body.innerHTML = '';
document.getElementById('shopTableEmpty').style.display = state.shops.length ? 'none' : 'block';
const countEl = document.getElementById('shopCount');
if (countEl) countEl.textContent = state.shops.length + ' 家';
for (const shop of state.shops) {
const tr = document.createElement('tr');
const tdCb = document.createElement('td');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true; cb.dataset.shopId = String(shop.shopId);
tdCb.appendChild(cb); tr.appendChild(tdCb);
const tdName = document.createElement('td');
tdName.innerHTML = `
${shop.shopName}
`;
tr.appendChild(tdName);
const tdId = document.createElement('td');
tdId.textContent = shop.shopId;
tdId.className = 'num';
tr.appendChild(tdId);
const tdStatus = document.createElement('td');
const st = state.statusByShopId.get(shop.shopId) || (shop.completed ? { state: 'skipped_completed' } : null);
tdStatus.innerHTML = st ? statusBadgeHtml(st) : '待抓取';
tr.appendChild(tdStatus);
const saved = shop.completed;
const folderName = state.folderNameByShopId.get(shop.shopId) || (saved ? saved.folderName : null);
const tdFolder = document.createElement('td');
tdFolder.className = 'col-folder';
tdFolder.innerHTML = folderName
? `${folderName}`
: '—';
tr.appendChild(tdFolder);
tr.dataset.shopId = String(shop.shopId);
body.appendChild(tr);
}
}
function statusBadgeHtml(st) {
const label = {
pending: '排队中', running: '抓取中',
done: `完成 ${st.downloaded}/${st.total || 0}`,
skipped_completed: '已完成',
failed: '失败', stopped: '已停止',
}[st.state] || st.state;
const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info';
return `${label}`;
}
const ICON_PLAY = '';
function renderCrawlState(cs) {
const badge = document.getElementById('crawlStatusBadge');
const labelMap = { idle: '空闲', running: '抓取中', done: '已完成', stopped: '已停止', error: '出错' };
const clsMap = { running: 'badge-warn', done: 'badge-ok', error: 'badge-fail', stopped: 'badge-warn' };
badge.textContent = labelMap[cs.status] || cs.status;
badge.className = 'badge ' + (clsMap[cs.status] || '');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const startIcon = document.getElementById('startBtnIcon');
const startText = document.getElementById('startBtnText');
startBtn.disabled = cs.status === 'running';
stopBtn.style.display = cs.status === 'running' ? '' : 'none';
if (cs.status === 'running') {
startIcon.innerHTML = '';
startText.textContent = '抓取中… 点击停止';
startBtn.classList.remove('btn-primary');
startBtn.classList.add('btn-ghost');
startBtn.disabled = false;
} else {
startIcon.innerHTML = ICON_PLAY;
startText.textContent = '开始抓取';
startBtn.classList.remove('btn-ghost');
startBtn.classList.add('btn-primary');
}
state.statusByShopId.clear();
state.folderNameByShopId.clear();
for (const s of cs.shops) {
state.statusByShopId.set(s.shopId, s);
if (s.folderName) state.folderNameByShopId.set(s.shopId, s.folderName);
}
renderShopTable();
const list = document.getElementById('progressList');
list.innerHTML = '';
if (!cs.shops.length) {
list.innerHTML = '';
}
for (const s of cs.shops) {
const row = document.createElement('div');
row.className = 'progress-row';
const pct = s.total > 0 ? Math.round((s.downloaded + s.skipped) / s.total * 100) : 0;
row.innerHTML = `
采集${s.shopName}· 菜品图
${s.downloaded + s.skipped} / ${s.total || 0}
${statusBadgeHtml(s)}
`;
list.appendChild(row);
}
if (cs.status !== 'running' && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
const tick = () => jsonFetch('/api/crawl/status').then(renderCrawlState).catch(() => {});
tick();
pollTimer = setInterval(tick, 1500);
}
function refreshShops() {
const root = crawlRoot;
const query = root ? `?root=${btoa(unescape(encodeURIComponent(root))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` : '';
return jsonFetch(`/api/shops${query}`).then((data) => { state.shops = data.shops || []; renderShopTable(); });
}
window.shanCrawlInit = function () {
if (initialized) return;
initialized = true;
const modal = document.getElementById('modalAddShop');
document.getElementById('btnToggleAdd').addEventListener('click', () => {
modal.hidden = false;
document.getElementById('manualInput').focus();
});
document.getElementById('btnCloseModal').addEventListener('click', () => { modal.hidden = true; });
document.getElementById('btnCancelAdd').addEventListener('click', () => { modal.hidden = true; });
modal.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.hidden) modal.hidden = true;
});
document.getElementById('advToggle').addEventListener('click', function () {
const open = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', String(!open));
document.getElementById('advBody').classList.toggle('is-open', !open);
});
document.getElementById('btnClearLog').addEventListener('click', () => {
document.getElementById('logPanel').innerHTML = '';
});
jsonFetch('/api/settings').then(s => {
crawlRoot = s.rootDir;
return refreshShops();
});
jsonFetch('/api/crawl/status').then((cs) => {
renderCrawlState(cs);
if (cs.status === 'running') startPolling();
});
document.getElementById('useManualBtn').addEventListener('click', () => {
const text = document.getElementById('manualInput').value;
if (!text.trim()) return showToast('请先填写手动清单', 'error');
jsonFetch('/api/shops/manual', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
}).then((data) => {
state.shops = data.shops; renderShopTable();
showToast(`已添加 ${data.addedCount} 家店铺`);
}).catch((e) => showToast('添加失败: ' + e.message, 'error'));
});
document.getElementById('selectAllCb').addEventListener('change', (e) => {
document.querySelectorAll('#shopTableBody input[type=checkbox]').forEach((cb) => { cb.checked = e.target.checked; });
});
document.getElementById('startBtn').addEventListener('click', () => {
if (document.getElementById('stopBtn').style.display !== 'none') {
jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
return;
}
const shopIds = Array.from(document.querySelectorAll('#shopTableBody input[type=checkbox]'))
.filter((cb) => cb.checked).map((cb) => Number(cb.dataset.shopId));
if (!shopIds.length) return showToast('请先在店铺列表里勾选要抓取的店铺', 'error');
const root = crawlRoot;
jsonFetch('/api/crawl/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root, shopIds, forceFullCrawl: document.getElementById('forceFullCrawl').checked }),
}).then(() => { showToast('已开始抓取'); startPolling(); })
.catch((e) => showToast('启动失败: ' + e.message, 'error'));
});
document.getElementById('stopBtn').addEventListener('click', () => {
jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
});
};
})();