'use strict'; (function () { const state = { shops: [], folderNameByShopId: new Map(), statusByShopId: new Map(), }; let pollTimer = null; let pollFailStreak = 0; 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; }) ); } // 店铺名/目录名来自外部平台数据,凡进 innerHTML 的文本必须先过 esc() function esc(value) { const div = document.createElement('div'); div.textContent = String(value ?? ''); return div.innerHTML; } function renderShopTable() { const body = document.getElementById('shopTableBody'); // 表格每 1.5s 随轮询全量重建,重建前记住用户已手动取消勾选的店铺,避免勾选被重置 const uncheckedBefore = new Set(); body.querySelectorAll('input[type=checkbox]').forEach((cb) => { if (!cb.checked) uncheckedBefore.add(cb.dataset.shopId); }); 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 = !uncheckedBefore.has(String(shop.shopId)); cb.dataset.shopId = String(shop.shopId); tdCb.appendChild(cb); tr.appendChild(tdCb); const tdName = document.createElement('td'); tdName.innerHTML = `
${esc(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 ? `${esc(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: '已停止', interrupted: '已中断', }[st.state] || st.state; const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info'; return `${esc(label)}`; } const ICON_PLAY = ''; function renderCrawlState(cs) { const badge = document.getElementById('crawlStatusBadge'); const labelMap = { idle: '空闲', running: '抓取中', done: '已完成', stopped: '已停止', error: '出错', interrupted: '已中断' }; const clsMap = { running: 'badge-warn', done: 'badge-ok', error: 'badge-fail', stopped: 'badge-warn', interrupted: '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 = `
采集${esc(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((cs) => { pollFailStreak = 0; renderCrawlState(cs); }).catch(() => { // 静默重试,但连续失败要给一次提示(服务可能重启),之后仍自动恢复 pollFailStreak++; if (pollFailStreak === 3) showToast('抓取状态轮询失败(服务可能已重启),恢复后自动继续', 'error'); }); 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(); }); } // 采集通道:按服务端探测结果给一行提示(客户端端口 / Cookie 是否就绪) function renderChannelHint(info) { const el = document.getElementById('crawlChannelHint'); if (!el) return; let msg; if (info.channel === 'client') { msg = info.clientUsable ? '将仅通过客户端采集(调试端口 9669 已就绪)' : '客户端未启动或调试端口(9669)未开:此模式下无法开始抓取'; } else if (info.channel === 'cookie') { msg = info.cookieConfigured ? '将仅通过 Cookie 采集' : '尚未填写 Cookie:请在「设置 → 采集配置」粘贴'; } else if (info.clientUsable) { msg = '已检测到客户端:将走客户端通道,失联时自动改用 Cookie'; } else if (info.cookieConfigured) { msg = '未检测到客户端(9669 未开):将使用 Cookie 通道'; } else { msg = '未检测到客户端,也未填写 Cookie:开始抓取前请至少配置其一'; } el.textContent = msg; } function refreshChannelInfo() { return jsonFetch('/api/crawl/channel').then(renderChannelHint).catch(() => {}); } 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('btnClearLog').addEventListener('click', () => { document.getElementById('logPanel').innerHTML = ''; }); jsonFetch('/api/settings').then(s => { crawlRoot = s.rootDir; if (s.crawlChannel) document.getElementById('crawlChannel').value = s.crawlChannel; refreshChannelInfo(); return refreshShops(); }); jsonFetch('/api/crawl/status').then((cs) => { renderCrawlState(cs); if (cs.status === 'running') startPolling(); }); document.getElementById('crawlChannel').addEventListener('change', function () { const value = this.value; jsonFetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ crawlChannel: value }), }).then(() => { showToast('采集通道已保存'); refreshChannelInfo(); }).catch((e) => showToast('保存失败: ' + e.message, 'error')); }); 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('已发送停止请求')); }); }; // 视图感知:离开采集页暂停状态轮询,回页时按服务端实际状态决定是否恢复 //(app.js switchView 调用;shanCrawlInit 只在首次进入时绑事件,不会重复拉状态) window.shanCrawlSetActive = function (active) { if (active) { refreshChannelInfo(); jsonFetch('/api/crawl/status').then((cs) => { renderCrawlState(cs); if (cs.status === 'running') startPolling(); }).catch(() => {}); } else if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }; })();