| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- '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 = `<div class="shop-name">${shop.shopName}</div>`;
- 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) : '<span class="badge">待抓取</span>';
- 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
- ? `<span class="badge badge-ok">${folderName}</span>`
- : '<span class="badge">—</span>';
- 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 `<span class="badge ${cls}">${label}</span>`;
- }
- const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
- 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 = '<span class="spinner spinner-inline"></span>';
- 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 = '<div class="empty-hint"><div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h16"/></svg></div><p>暂无任务</p></div>';
- }
- 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 = `
- <div class="left"><span class="badge badge-info">采集</span><span class="name">${s.shopName}</span><span class="meta">· 菜品图</span></div>
- <div class="right">
- <div class="progress-bar"><span style="width:${pct}%;"></span></div>
- <span>${s.downloaded + s.skipped} / ${s.total || 0}</span>
- ${statusBadgeHtml(s)}
- </div>`;
- 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('已发送停止请求'));
- });
- };
- })();
|