crawl.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. 'use strict';
  2. (function () {
  3. const state = {
  4. shops: [],
  5. folderNameByShopId: new Map(),
  6. statusByShopId: new Map(),
  7. };
  8. let pollTimer = null;
  9. let crawlRoot = '';
  10. let initialized = false;
  11. function showToast(msg, type) {
  12. const wrap = document.getElementById('toast-wrap');
  13. const el = document.createElement('div');
  14. el.className = 'toast toast-' + (type || 'success');
  15. el.textContent = msg;
  16. wrap.appendChild(el);
  17. setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s'; }, 2400);
  18. setTimeout(() => { if (el.parentNode) wrap.removeChild(el); }, 2800);
  19. }
  20. function jsonFetch(url, opts) {
  21. return fetch(url, opts).then((r) =>
  22. r.json().then((data) => {
  23. if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
  24. return data;
  25. })
  26. );
  27. }
  28. function renderShopTable() {
  29. const body = document.getElementById('shopTableBody');
  30. body.innerHTML = '';
  31. document.getElementById('shopTableEmpty').style.display = state.shops.length ? 'none' : 'block';
  32. const countEl = document.getElementById('shopCount');
  33. if (countEl) countEl.textContent = state.shops.length + ' 家';
  34. for (const shop of state.shops) {
  35. const tr = document.createElement('tr');
  36. const tdCb = document.createElement('td');
  37. const cb = document.createElement('input');
  38. cb.type = 'checkbox'; cb.checked = true; cb.dataset.shopId = String(shop.shopId);
  39. tdCb.appendChild(cb); tr.appendChild(tdCb);
  40. const tdName = document.createElement('td');
  41. tdName.innerHTML = `<div class="shop-name">${shop.shopName}</div>`;
  42. tr.appendChild(tdName);
  43. const tdId = document.createElement('td');
  44. tdId.textContent = shop.shopId;
  45. tdId.className = 'num';
  46. tr.appendChild(tdId);
  47. const tdStatus = document.createElement('td');
  48. const st = state.statusByShopId.get(shop.shopId) || (shop.completed ? { state: 'skipped_completed' } : null);
  49. tdStatus.innerHTML = st ? statusBadgeHtml(st) : '<span class="badge">待抓取</span>';
  50. tr.appendChild(tdStatus);
  51. const saved = shop.completed;
  52. const folderName = state.folderNameByShopId.get(shop.shopId) || (saved ? saved.folderName : null);
  53. const tdFolder = document.createElement('td');
  54. tdFolder.className = 'col-folder';
  55. tdFolder.innerHTML = folderName
  56. ? `<span class="badge badge-ok">${folderName}</span>`
  57. : '<span class="badge">—</span>';
  58. tr.appendChild(tdFolder);
  59. tr.dataset.shopId = String(shop.shopId);
  60. body.appendChild(tr);
  61. }
  62. }
  63. function statusBadgeHtml(st) {
  64. const label = {
  65. pending: '排队中', running: '抓取中',
  66. done: `完成 ${st.downloaded}/${st.total || 0}`,
  67. skipped_completed: '已完成',
  68. failed: '失败', stopped: '已停止',
  69. }[st.state] || st.state;
  70. const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info';
  71. return `<span class="badge ${cls}">${label}</span>`;
  72. }
  73. const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
  74. function renderCrawlState(cs) {
  75. const badge = document.getElementById('crawlStatusBadge');
  76. const labelMap = { idle: '空闲', running: '抓取中', done: '已完成', stopped: '已停止', error: '出错' };
  77. const clsMap = { running: 'badge-warn', done: 'badge-ok', error: 'badge-fail', stopped: 'badge-warn' };
  78. badge.textContent = labelMap[cs.status] || cs.status;
  79. badge.className = 'badge ' + (clsMap[cs.status] || '');
  80. const startBtn = document.getElementById('startBtn');
  81. const stopBtn = document.getElementById('stopBtn');
  82. const startIcon = document.getElementById('startBtnIcon');
  83. const startText = document.getElementById('startBtnText');
  84. startBtn.disabled = cs.status === 'running';
  85. stopBtn.style.display = cs.status === 'running' ? '' : 'none';
  86. if (cs.status === 'running') {
  87. startIcon.innerHTML = '<span class="spinner spinner-inline"></span>';
  88. startText.textContent = '抓取中… 点击停止';
  89. startBtn.classList.remove('btn-primary');
  90. startBtn.classList.add('btn-ghost');
  91. startBtn.disabled = false;
  92. } else {
  93. startIcon.innerHTML = ICON_PLAY;
  94. startText.textContent = '开始抓取';
  95. startBtn.classList.remove('btn-ghost');
  96. startBtn.classList.add('btn-primary');
  97. }
  98. state.statusByShopId.clear();
  99. state.folderNameByShopId.clear();
  100. for (const s of cs.shops) {
  101. state.statusByShopId.set(s.shopId, s);
  102. if (s.folderName) state.folderNameByShopId.set(s.shopId, s.folderName);
  103. }
  104. renderShopTable();
  105. const list = document.getElementById('progressList');
  106. list.innerHTML = '';
  107. if (!cs.shops.length) {
  108. 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>';
  109. }
  110. for (const s of cs.shops) {
  111. const row = document.createElement('div');
  112. row.className = 'progress-row';
  113. const pct = s.total > 0 ? Math.round((s.downloaded + s.skipped) / s.total * 100) : 0;
  114. row.innerHTML = `
  115. <div class="left"><span class="badge badge-info">采集</span><span class="name">${s.shopName}</span><span class="meta">· 菜品图</span></div>
  116. <div class="right">
  117. <div class="progress-bar"><span style="width:${pct}%;"></span></div>
  118. <span>${s.downloaded + s.skipped} / ${s.total || 0}</span>
  119. ${statusBadgeHtml(s)}
  120. </div>`;
  121. list.appendChild(row);
  122. }
  123. if (cs.status !== 'running' && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
  124. }
  125. function startPolling() {
  126. if (pollTimer) clearInterval(pollTimer);
  127. const tick = () => jsonFetch('/api/crawl/status').then(renderCrawlState).catch(() => {});
  128. tick();
  129. pollTimer = setInterval(tick, 1500);
  130. }
  131. function refreshShops() {
  132. const root = crawlRoot;
  133. const query = root ? `?root=${btoa(unescape(encodeURIComponent(root))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` : '';
  134. return jsonFetch(`/api/shops${query}`).then((data) => { state.shops = data.shops || []; renderShopTable(); });
  135. }
  136. window.shanCrawlInit = function () {
  137. if (initialized) return;
  138. initialized = true;
  139. const modal = document.getElementById('modalAddShop');
  140. document.getElementById('btnToggleAdd').addEventListener('click', () => {
  141. modal.hidden = false;
  142. document.getElementById('manualInput').focus();
  143. });
  144. document.getElementById('btnCloseModal').addEventListener('click', () => { modal.hidden = true; });
  145. document.getElementById('btnCancelAdd').addEventListener('click', () => { modal.hidden = true; });
  146. modal.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
  147. document.addEventListener('keydown', (e) => {
  148. if (e.key === 'Escape' && !modal.hidden) modal.hidden = true;
  149. });
  150. document.getElementById('advToggle').addEventListener('click', function () {
  151. const open = this.getAttribute('aria-expanded') === 'true';
  152. this.setAttribute('aria-expanded', String(!open));
  153. document.getElementById('advBody').classList.toggle('is-open', !open);
  154. });
  155. document.getElementById('btnClearLog').addEventListener('click', () => {
  156. document.getElementById('logPanel').innerHTML = '';
  157. });
  158. jsonFetch('/api/settings').then(s => {
  159. crawlRoot = s.rootDir;
  160. return refreshShops();
  161. });
  162. jsonFetch('/api/crawl/status').then((cs) => {
  163. renderCrawlState(cs);
  164. if (cs.status === 'running') startPolling();
  165. });
  166. document.getElementById('useManualBtn').addEventListener('click', () => {
  167. const text = document.getElementById('manualInput').value;
  168. if (!text.trim()) return showToast('请先填写手动清单', 'error');
  169. jsonFetch('/api/shops/manual', {
  170. method: 'POST', headers: { 'Content-Type': 'application/json' },
  171. body: JSON.stringify({ text }),
  172. }).then((data) => {
  173. state.shops = data.shops; renderShopTable();
  174. showToast(`已添加 ${data.addedCount} 家店铺`);
  175. }).catch((e) => showToast('添加失败: ' + e.message, 'error'));
  176. });
  177. document.getElementById('selectAllCb').addEventListener('change', (e) => {
  178. document.querySelectorAll('#shopTableBody input[type=checkbox]').forEach((cb) => { cb.checked = e.target.checked; });
  179. });
  180. document.getElementById('startBtn').addEventListener('click', () => {
  181. if (document.getElementById('stopBtn').style.display !== 'none') {
  182. jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
  183. return;
  184. }
  185. const shopIds = Array.from(document.querySelectorAll('#shopTableBody input[type=checkbox]'))
  186. .filter((cb) => cb.checked).map((cb) => Number(cb.dataset.shopId));
  187. if (!shopIds.length) return showToast('请先在店铺列表里勾选要抓取的店铺', 'error');
  188. const root = crawlRoot;
  189. jsonFetch('/api/crawl/start', {
  190. method: 'POST', headers: { 'Content-Type': 'application/json' },
  191. body: JSON.stringify({ root, shopIds, forceFullCrawl: document.getElementById('forceFullCrawl').checked }),
  192. }).then(() => { showToast('已开始抓取'); startPolling(); })
  193. .catch((e) => showToast('启动失败: ' + e.message, 'error'));
  194. });
  195. document.getElementById('stopBtn').addEventListener('click', () => {
  196. jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
  197. });
  198. };
  199. })();