collection_management.html 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. {% extends "base.html" %}
  2. {% block content %}
  3. <div class="flex h-screen overflow-hidden" id="collection-view">
  4. <!-- Sidebar -->
  5. {% include 'partials/sidebar.html' %}
  6. <!-- Main Content -->
  7. <div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-gray-900/80 relative">
  8. <!-- Top Header -->
  9. <header class="h-16 flex items-center justify-between px-6 z-20 bg-gray-900/80 backdrop-blur-md border-b border-blue-900/30">
  10. <button class="md:hidden text-gray-300 focus:outline-none" id="open-sidebar">
  11. <i class="fas fa-bars text-xl"></i>
  12. </button>
  13. <h1 class="text-lg md:text-2xl font-bold tech-title truncate ml-2">采集任务管理</h1>
  14. <div class="flex items-center space-x-4">
  15. <div class="flex items-center space-x-2">
  16. <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold border border-cyan-400 shadow-md">A</div>
  17. <span class="hidden md:inline text-sm text-gray-300">{{ current_user.username }}</span>
  18. </div>
  19. </div>
  20. </header>
  21. <!-- Main Scrollable Area -->
  22. <main class="flex-1 overflow-y-auto p-4 md:p-6 scroll-smooth">
  23. <div class="h-full flex flex-col gap-6">
  24. <!-- 1. Keyword Input -->
  25. <div class="bg-gray-800 p-6 rounded-lg shadow-lg">
  26. <label class="block text-gray-400 text-sm mb-2 font-bold">1. 采集关键词</label>
  27. <div class="flex gap-4 items-end">
  28. <div class="flex-1">
  29. <input type="text" id="keyword" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="请输入关键词...">
  30. </div>
  31. <div id="page-count-container" class="hidden w-32">
  32. <label class="block text-gray-400 text-sm mb-1 font-bold">采集页数</label>
  33. <input type="number" id="page-count" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-blue-500" value="1" min="1" max="100">
  34. </div>
  35. <button onclick="startCollection()" class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded font-bold transition-colors flex items-center gap-2 h-[50px]">
  36. <i class="fas fa-play"></i> 开始采集
  37. </button>
  38. </div>
  39. </div>
  40. <!-- 2. Spider Source Switch Panel -->
  41. <div class="bg-gray-800 p-6 rounded-lg shadow-lg">
  42. <label class="block text-gray-400 text-sm mb-4 font-bold">2. 选择爬虫源 (开关模式)</label>
  43. <div id="source-list" class="flex flex-wrap gap-4">
  44. <!-- Sources will be injected here -->
  45. <div class="animate-pulse bg-gray-700 h-10 w-32 rounded"></div>
  46. </div>
  47. </div>
  48. <!-- 3. Showcase Data List -->
  49. <div class="flex-1 bg-gray-800 p-6 rounded-lg shadow-lg flex flex-col min-h-0">
  50. <div class="flex justify-between items-center mb-4">
  51. <label class="text-gray-400 text-sm font-bold">3. 采集结果橱窗</label>
  52. <div class="flex gap-2 items-center">
  53. <button onclick="openManualModal()" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-1 rounded text-sm transition-colors">
  54. <i class="fas fa-plus"></i> 手动入库
  55. </button>
  56. <span id="result-count" class="text-gray-500 text-sm"></span>
  57. <button onclick="toggleSelectAll()" id="btn-select-all" class="hidden bg-gray-600 hover:bg-gray-500 text-white px-3 py-1 rounded text-sm transition-colors">
  58. <i class="fas fa-check-double"></i> 全选
  59. </button>
  60. <button onclick="saveSelected()" id="btn-save" class="hidden bg-green-600 hover:bg-green-700 text-white px-4 py-1 rounded text-sm transition-colors">
  61. <i class="fas fa-save"></i> 保存选中数据
  62. </button>
  63. </div>
  64. </div>
  65. <div id="results-container" class="flex-1 overflow-y-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pr-2 custom-scrollbar" style="min-height: 300px;">
  66. <!-- Results will be injected here -->
  67. <div class="col-span-full text-center text-gray-500 py-20">
  68. <i class="fas fa-search text-4xl mb-4 opacity-50"></i>
  69. <p>请输入关键词并选择爬虫源开始采集</p>
  70. </div>
  71. </div>
  72. </div>
  73. </div>
  74. </main>
  75. </div>
  76. </div>
  77. <style>
  78. /* Custom Switch Style */
  79. .source-switch input:checked + div {
  80. background-color: #3B82F6;
  81. color: white;
  82. border-color: #3B82F6;
  83. }
  84. .source-switch input:checked + div .check-icon {
  85. display: block;
  86. }
  87. /* Card Selection Style */
  88. .result-card.selected {
  89. border-color: #3B82F6;
  90. background-color: #1F2937; /* Slightly lighter gray */
  91. position: relative;
  92. }
  93. .result-card.selected::after {
  94. content: '\f00c';
  95. font-family: 'Font Awesome 5 Free';
  96. font-weight: 900;
  97. position: absolute;
  98. top: 10px;
  99. right: 10px;
  100. background: #3B82F6;
  101. color: white;
  102. width: 24px;
  103. height: 24px;
  104. border-radius: 50%;
  105. display: flex;
  106. align-items: center;
  107. justify-content: center;
  108. font-size: 12px;
  109. }
  110. </style>
  111. <script>
  112. let currentResults = [];
  113. let selectedIndices = new Set();
  114. let sourcesMap = {};
  115. $(document).ready(function() {
  116. loadSources();
  117. // Sidebar Toggle Logic
  118. $('#open-sidebar').click(function() {
  119. $('.sidebar').toggleClass('-translate-x-full');
  120. });
  121. });
  122. function loadSources() {
  123. $.get('/collection/api/sources', function(data) {
  124. const container = $('#source-list');
  125. container.empty();
  126. sourcesMap = {};
  127. if (data.length === 0) {
  128. container.html('<p class="text-gray-500">暂无可用爬虫源</p>');
  129. return;
  130. }
  131. data.forEach((source, index) => {
  132. sourcesMap[source.id] = source;
  133. const html = `
  134. <label class="source-switch cursor-pointer select-none">
  135. <input type="radio" name="spider_source" value="${source.id}" class="hidden" ${index === 0 ? 'checked' : ''}>
  136. <div class="bg-gray-700 border border-gray-600 rounded-full px-6 py-2 text-gray-300 transition-all hover:bg-gray-600 flex items-center gap-2">
  137. <span>${source.name}</span>
  138. <i class="fas fa-check check-icon hidden text-xs"></i>
  139. </div>
  140. </label>
  141. `;
  142. container.append(html);
  143. });
  144. $('input[name="spider_source"]').change(updatePageInputVisibility);
  145. if (data.length > 0) updatePageInputVisibility();
  146. });
  147. }
  148. function updatePageInputVisibility() {
  149. const sourceId = $('input[name="spider_source"]:checked').val();
  150. const source = sourcesMap[sourceId];
  151. if (source && source.has_pagination) {
  152. $('#page-count-container').removeClass('hidden');
  153. } else {
  154. $('#page-count-container').addClass('hidden');
  155. }
  156. }
  157. function startCollection() {
  158. const keyword = $('#keyword').val().trim();
  159. const sourceId = $('input[name="spider_source"]:checked').val();
  160. const pageCount = $('#page-count').val();
  161. if (!keyword) {
  162. alert('请输入关键词');
  163. return;
  164. }
  165. if (!sourceId) {
  166. alert('请选择爬虫源');
  167. return;
  168. }
  169. // Reset UI
  170. const container = $('#results-container');
  171. container.html(`
  172. <div class="col-span-full text-center text-gray-500 py-20">
  173. <i class="fas fa-circle-notch fa-spin text-4xl mb-4 text-blue-500"></i>
  174. <p>正在采集数据,请稍候...</p>
  175. </div>
  176. `);
  177. $('#btn-save').addClass('hidden');
  178. $('#result-count').text('');
  179. currentResults = [];
  180. selectedIndices.clear();
  181. // Call API
  182. $.ajax({
  183. url: '/collection/api/preview',
  184. method: 'POST',
  185. contentType: 'application/json',
  186. data: JSON.stringify({
  187. keyword: keyword,
  188. source_id: sourceId,
  189. pages: pageCount
  190. }),
  191. success: function(response) {
  192. renderResults(response.results);
  193. },
  194. error: function(xhr) {
  195. container.html(`
  196. <div class="col-span-full text-center text-red-500 py-20">
  197. <i class="fas fa-exclamation-circle text-4xl mb-4"></i>
  198. <p>采集失败: ${xhr.responseJSON?.error || '未知错误'}</p>
  199. </div>
  200. `);
  201. }
  202. });
  203. }
  204. function renderResults(results) {
  205. const container = $('#results-container');
  206. container.empty();
  207. currentResults = results;
  208. selectedIndices.clear(); // Reset selection on new search
  209. $('#btn-select-all').text('全选').removeClass('bg-blue-600').addClass('bg-gray-600');
  210. if (results.length === 0) {
  211. container.html(`
  212. <div class="col-span-full text-center text-gray-500 py-20">
  213. <p>未找到相关数据</p>
  214. </div>
  215. `);
  216. $('#btn-select-all').addClass('hidden');
  217. $('#btn-save').addClass('hidden');
  218. return;
  219. }
  220. results.forEach((item, index) => {
  221. const coverHtml = item.cover ?
  222. `<img src="${item.cover}" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.src='https://via.placeholder.com/300x200?text=No+Image'">` :
  223. `<div class="w-full h-full bg-gray-700 flex items-center justify-center text-gray-500"><i class="fas fa-image text-3xl"></i></div>`;
  224. const html = `
  225. <div id="result-card-${index}" class="result-card bg-gray-700 rounded-lg border border-gray-600 cursor-pointer hover:shadow-xl transition-all flex flex-col group relative"
  226. onclick="toggleSelection(${index}, this)">
  227. <div class="h-40 w-full flex-none overflow-hidden rounded-t-lg relative">
  228. ${coverHtml}
  229. </div>
  230. <div class="p-4 flex flex-col flex-1">
  231. <h3 class="text-white font-bold mb-2 text-sm md:text-base break-words hover:text-blue-400"
  232. style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5em; max-height: 3em;"
  233. title="${item.title || ''}"
  234. onclick="event.stopPropagation(); window.open('${item.link}', '_blank')">
  235. ${item.title || '<span class="text-gray-500 italic">(无标题)</span>'}
  236. </h3>
  237. <div class="mt-auto pt-2 border-t border-gray-600 flex justify-between items-center text-xs text-gray-400">
  238. <span class="truncate max-w-[120px]" title="${item.source || ''}">
  239. <i class="fas fa-globe mr-1 text-blue-400"></i>${item.source || '未知来源'}
  240. </span>
  241. <span class="whitespace-nowrap ml-2">
  242. <i class="far fa-clock mr-1 text-green-400"></i>${item.date || '未知时间'}
  243. </span>
  244. </div>
  245. </div>
  246. </div>
  247. `;
  248. container.append(html);
  249. });
  250. $('#result-count').text(`共找到 ${results.length} 条数据`);
  251. $('#btn-save').removeClass('hidden').html(`<i class="fas fa-save"></i> 保存选中数据`);
  252. $('#btn-select-all').removeClass('hidden');
  253. if (container[0].children.length > 0) {
  254. container[0].children[0].scrollIntoView({ behavior: 'smooth' });
  255. }
  256. }
  257. function toggleSelection(index, element) {
  258. if (selectedIndices.has(index)) {
  259. selectedIndices.delete(index);
  260. $(element).removeClass('selected');
  261. } else {
  262. selectedIndices.add(index);
  263. $(element).addClass('selected');
  264. }
  265. updateSaveButton();
  266. }
  267. function toggleSelectAll() {
  268. const btn = $('#btn-select-all');
  269. // Check if current state is "Select All" (i.e. not "Cancel")
  270. const isSelectAll = !btn.text().includes('取消');
  271. if (isSelectAll) {
  272. // Select All
  273. currentResults.forEach((_, index) => {
  274. selectedIndices.add(index);
  275. $(`#result-card-${index}`).addClass('selected');
  276. });
  277. btn.html('<i class="fas fa-times"></i> 取消全选').removeClass('bg-gray-600').addClass('bg-blue-600');
  278. } else {
  279. // Deselect All
  280. selectedIndices.clear();
  281. $('.result-card.selected').removeClass('selected');
  282. btn.html('<i class="fas fa-check-double"></i> 全选').removeClass('bg-blue-600').addClass('bg-gray-600');
  283. }
  284. updateSaveButton();
  285. }
  286. function updateSaveButton() {
  287. const count = selectedIndices.size;
  288. $('#btn-save').html(`<i class="fas fa-save"></i> 保存选中数据 (${count})`);
  289. }
  290. function saveSelected() {
  291. if (selectedIndices.size === 0) {
  292. alert('请至少选择一条数据');
  293. return;
  294. }
  295. const itemsToSave = Array.from(selectedIndices).map(i => currentResults[i]);
  296. const keyword = $('#keyword').val().trim();
  297. const sourceId = $('input[name="spider_source"]:checked').val();
  298. $.ajax({
  299. url: '/collection/api/save_selected',
  300. method: 'POST',
  301. contentType: 'application/json',
  302. data: JSON.stringify({
  303. keyword: keyword,
  304. source_id: sourceId,
  305. items: itemsToSave
  306. }),
  307. success: function(response) {
  308. alert(`保存成功!\n新增: ${response.added}\n更新: ${response.updated}`);
  309. // Remove saved items from view
  310. Array.from(selectedIndices).sort((a, b) => b - a).forEach(index => {
  311. $(`#result-card-${index}`).remove();
  312. // Also remove from currentResults to keep indices valid?
  313. // Actually removing from DOM invalidates indices if we rely on them for future clicks.
  314. // But we just removed them, so we can't click them.
  315. // However, if we don't remove from currentResults, the indices in currentResults match the initial render.
  316. // But if we remove from DOM, the user can't select them again.
  317. // The issue is if I select item 0 and 2, save them, they are gone.
  318. // Item 1 is still there. If I click Item 1, it calls toggleSelection(1).
  319. // This is fine because I am NOT modifying currentResults array, just hiding/removing DOM elements.
  320. });
  321. // Clear selection
  322. selectedIndices.clear();
  323. updateSaveButton();
  324. // Reset Select All button if needed
  325. $('#btn-select-all').html('<i class="fas fa-check-double"></i> 全选').removeClass('bg-blue-600').addClass('bg-gray-600');
  326. // Update count
  327. const remaining = $('.result-card').length;
  328. $('#result-count').text(`剩余 ${remaining} 条数据`);
  329. if (remaining === 0) {
  330. $('#results-container').html(`
  331. <div class="col-span-full text-center text-gray-500 py-20">
  332. <p>所有数据已保存</p>
  333. </div>
  334. `);
  335. $('#btn-select-all').addClass('hidden');
  336. $('#btn-save').addClass('hidden');
  337. }
  338. },
  339. error: function(xhr) {
  340. alert('保存失败: ' + (xhr.responseJSON?.error || '未知错误'));
  341. }
  342. });
  343. }
  344. function openManualModal() {
  345. $('#manual-modal').removeClass('hidden');
  346. $('#manual-link').val('');
  347. $('#manual-title').val('');
  348. $('#manual-abstract').val('');
  349. $('#manual-source').val('');
  350. $('#manual-cover').val('');
  351. $('#manual-date').val('');
  352. }
  353. function closeManualModal() {
  354. $('#manual-modal').addClass('hidden');
  355. }
  356. function addManualEntry() {
  357. const link = $('#manual-link').val().trim();
  358. if (!link) {
  359. alert('链接地址为必填项');
  360. return;
  361. }
  362. const item = {
  363. title: $('#manual-title').val().trim(),
  364. abstract: $('#manual-abstract').val().trim(),
  365. source: $('#manual-source').val().trim(),
  366. cover: $('#manual-cover').val().trim(),
  367. link: link,
  368. published_at: $('#manual-date').val().trim()
  369. };
  370. $.ajax({
  371. url: '/collection/api/manual-save',
  372. method: 'POST',
  373. contentType: 'application/json',
  374. data: JSON.stringify({ items: [item] }),
  375. success: function(response) {
  376. const msg = response.added > 0 ? `新增 ${response.added} 条` : `更新 ${response.updated} 条`;
  377. alert('保存成功!' + msg);
  378. closeManualModal();
  379. // Add to showcase display
  380. const idx = currentResults.length;
  381. currentResults.push(item);
  382. const container = $('#results-container');
  383. // Remove placeholder if exists
  384. if (container.find('.col-span-full').length > 0) {
  385. container.empty();
  386. }
  387. const coverHtml = item.cover ?
  388. `<img src="${item.cover}" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.src='https://via.placeholder.com/300x200?text=No+Image'">` :
  389. `<div class="w-full h-full bg-gray-700 flex items-center justify-center text-gray-500"><i class="fas fa-image text-3xl"></i></div>`;
  390. const html = `
  391. <div id="result-card-${idx}" class="result-card bg-gray-700 rounded-lg border border-gray-600 cursor-pointer hover:shadow-xl transition-all flex flex-col group relative"
  392. onclick="toggleSelection(${idx}, this)">
  393. <div class="h-40 w-full flex-none overflow-hidden rounded-t-lg relative">
  394. ${coverHtml}
  395. </div>
  396. <div class="p-4 flex flex-col flex-1">
  397. <h3 class="text-white font-bold mb-2 text-sm md:text-base break-words hover:text-blue-400"
  398. style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5em; max-height: 3em;"
  399. title="${item.title || ''}"
  400. onclick="event.stopPropagation(); window.open('${item.link}', '_blank')">
  401. ${item.title || '<span class="text-gray-500 italic">(无标题)</span>'}
  402. </h3>
  403. <div class="mt-auto pt-2 border-t border-gray-600 flex justify-between items-center text-xs text-gray-400">
  404. <span class="truncate max-w-[120px]" title="${item.source || ''}">
  405. <i class="fas fa-globe mr-1 text-blue-400"></i>${item.source || '未知来源'}
  406. </span>
  407. <span class="whitespace-nowrap ml-2">
  408. <i class="far fa-clock mr-1 text-green-400"></i>${item.published_at || '未知时间'}
  409. </span>
  410. </div>
  411. </div>
  412. </div>
  413. `;
  414. container.append(html);
  415. $('#btn-save').removeClass('hidden');
  416. $('#result-count').text(`共 ${currentResults.length} 条数据`);
  417. },
  418. error: function(xhr) {
  419. alert('保存失败: ' + (xhr.responseJSON?.error || '未知错误'));
  420. }
  421. });
  422. }
  423. </script>
  424. <!-- Manual Input Modal -->
  425. <div id="manual-modal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
  426. <div class="bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg mx-4 border border-gray-700">
  427. <div class="flex justify-between items-center mb-4">
  428. <h2 class="text-xl font-bold text-white"><i class="fas fa-plus text-purple-500 mr-2"></i>手动入库</h2>
  429. <button onclick="closeManualModal()" class="text-gray-400 hover:text-white">
  430. <i class="fas fa-times text-xl"></i>
  431. </button>
  432. </div>
  433. <div class="space-y-4">
  434. <div>
  435. <label class="block text-gray-400 text-sm mb-1 font-bold">链接地址 <span class="text-red-500">*</span></label>
  436. <input type="text" id="manual-link" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-purple-500" placeholder="https://...">
  437. </div>
  438. <div>
  439. <label class="block text-gray-400 text-sm mb-1 font-bold">标题</label>
  440. <input type="text" id="manual-title" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-purple-500" placeholder="文章标题">
  441. </div>
  442. <div>
  443. <label class="block text-gray-400 text-sm mb-1 font-bold">摘要</label>
  444. <textarea id="manual-abstract" rows="3" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-purple-500" placeholder="内容摘要"></textarea>
  445. </div>
  446. <div class="flex gap-4">
  447. <div class="flex-1">
  448. <label class="block text-gray-400 text-sm mb-1 font-bold">来源</label>
  449. <input type="text" id="manual-source" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-purple-500" placeholder="来源网站">
  450. </div>
  451. <div class="flex-1">
  452. <label class="block text-gray-400 text-sm mb-1 font-bold">发布日期</label>
  453. <input type="text" id="manual-date" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-purple-500" placeholder="2026-05-20">
  454. </div>
  455. </div>
  456. <div>
  457. <label class="block text-gray-400 text-sm mb-1 font-bold">封面图片 URL</label>
  458. <input type="text" id="manual-cover" class="w-full bg-gray-700 text-white rounded p-3 focus:outline-none focus:ring-2 focus:ring-purple-500" placeholder="https://...">
  459. </div>
  460. </div>
  461. <div class="flex justify-end gap-3 mt-6">
  462. <button onclick="closeManualModal()" class="bg-gray-600 hover:bg-gray-500 text-white px-6 py-2 rounded transition-colors">取消</button>
  463. <button onclick="addManualEntry()" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded transition-colors">
  464. <i class="fas fa-save mr-1"></i>保存入库
  465. </button>
  466. </div>
  467. </div>
  468. </div>
  469. {% endblock %}