diff --git a/module/templates/index.html b/module/templates/index.html index b27085f..29d4949 100644 --- a/module/templates/index.html +++ b/module/templates/index.html @@ -580,6 +580,39 @@ padding: 14px 24px; border-top: 1px solid var(--border); gap: 10px; } + /* ════ 下载队列 ════ */ + .submit-row { display: flex; gap: 6px; align-items: stretch; } + .btn-add-queue { + padding: 9px 14px; border-radius: 7px; + background: transparent; border: 1px solid var(--border); + color: var(--muted); font-size: 13px; font-weight: 600; + cursor: pointer; transition: all .15s; white-space: nowrap; + } + .btn-add-queue:hover { border-color: var(--accent); color: var(--accent); } + .queue-item { + display: flex; align-items: center; gap: 10px; + padding: 9px 2px; border-bottom: 1px solid var(--border); + } + .queue-item:last-child { border-bottom: none; } + .queue-num { + width: 22px; height: 22px; border-radius: 50%; + background: rgba(88,166,255,.12); border: 1px solid rgba(88,166,255,.25); + display: flex; align-items: center; justify-content: center; + font-size: 11px; font-weight: 700; color: var(--accent); flex-shrink: 0; + } + .queue-info { flex: 1; min-width: 0; } + .queue-name { font-size: 13px; font-weight: 500; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .queue-filter { font-size: 11px; color: var(--muted); margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .queue-del { + width: 26px; height: 26px; border: none; background: none; + color: var(--muted); cursor: pointer; border-radius: 4px; + display: flex; align-items: center; justify-content: center; + font-size: 13px; flex-shrink: 0; transition: all .12s; + } + .queue-del:hover { background: rgba(248,81,73,.15); color: var(--red); } + /* 响应式:手机适配(≤ 768px) */ @media (max-width: 768px) { /* 顶部导航变两行,把 --hdr-h 调大供 formbar margin 跟随 */ @@ -629,7 +662,10 @@ } .fb-field { padding: 0; width: 100%; border-right: none !important; min-width: 0; } .fb-field-submit { padding: 0; } + .submit-row { flex-direction: column; gap: 8px; } + .btn-add-queue { width: 100%; padding: 11px; font-size: 13px; } .btn-submit { width: 100%; justify-content: center; padding: 12px; font-size: 14px; } + .queue-filter { font-size: 10px; } /* 验证下拉宽度自适应,避免溢出 */ .val-drop { width: auto; left: 12px; right: 12px; } @@ -927,9 +963,14 @@
- +
+ + +
@@ -958,6 +999,18 @@
当前任务
+ + +
@@ -1211,41 +1264,147 @@ }); }); - // ── 保存并重启 ── - function saveAndRestart() { + // ── 下载队列 ── + let downloadQueue = []; + + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + })[c]); + } + + function buildFilterFromForm(startDate, endDate) { + const custom = filterExpression || ''; + if (custom) return custom; + if (!startDate) return ''; + return 'message_date >= ' + startDate + ' 00:00:00' + + (endDate ? ' and message_date <= ' + endDate + ' 23:59:59' : ''); + } + + function renderQueue() { + const card = document.getElementById('queue-card'); + const list = document.getElementById('queue-list'); + const count = document.getElementById('queue-count'); + if (!downloadQueue.length) { card.style.display = 'none'; return; } + card.style.display = ''; + count.textContent = downloadQueue.length + ' 个'; + list.innerHTML = downloadQueue.map((q, i) => ` +
+
${i + 1}
+
+
${escapeHtml(q.chat_title || q.chat_id)}
+
${escapeHtml(q.download_filter || '无过滤条件')}
+
+ +
+ `).join(''); + } + + function addToQueue() { const channelId = parseChannelId(document.getElementById('channel-input').value); const startDate = document.getElementById('start-date').value; const endDate = document.getElementById('end-date').value; if (!channelId) { toast('请输入有效的频道', 'err'); return; } if (!startDate) { toast('请选择开始日期', 'err'); return; } + if (downloadQueue.some(q => q.chat_id === channelId)) { + toast('该频道已在队列中', 'err'); return; + } - const doSave = (chatTitle, chatType) => { - const btn = document.getElementById('save-btn'); - btn.disabled = true; btn.innerHTML = ' 重启中…'; - const submittedFilter = filterExpression || ''; - const builtFilter = startDate - ? ('message_date >= ' + startDate + ' 00:00:00' + (endDate ? ' and message_date <= ' + endDate + ' 23:59:59' : '')) - : ''; - fetch('/api/save_and_restart', { - method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({ chat_id:channelId, start_date:startDate, end_date:endDate, chat_title:chatTitle, chat_type:chatType||'', download_filter:submittedFilter }) - }).then(r => r.json()).then(d => { - if (d.success) { - currentFilter = submittedFilter || builtFilter; - updateBannerFilter(); - toast('✅ ' + d.message + ',5秒后刷新…'); setTimeout(() => location.reload(), 5000); - } else { toast('失败: ' + (d.error||''), 'err'); btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; } - }).catch(e => { toast('操作失败: ' + e.message, 'err'); btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; }); + const doAdd = (chatTitle, chatType) => { + downloadQueue.push({ + chat_id: channelId, + chat_title: chatTitle || channelId, + chat_type: chatType || '', + download_filter: buildFilterFromForm(startDate, endDate), + start_date: startDate, + end_date: endDate, + }); + renderQueue(); + // 清空表单以便继续添加下一个频道 + document.getElementById('channel-input').value = ''; + validatedChannel = null; + const valBox = document.getElementById('val-result'); + if (valBox) valBox.className = 'val-drop'; + toast('已加入队列: ' + (chatTitle || channelId)); }; - if (validatedChannel) { - doSave(validatedChannel.chat_title, validatedChannel.chat_type); + const btn = document.getElementById('add-queue-btn'); + if (validatedChannel && validatedChannel.chat_id === channelId) { + doAdd(validatedChannel.chat_title, validatedChannel.chat_type); } else { - const btn = document.getElementById('save-btn'); - btn.disabled = true; btn.innerHTML = ' 验证中…'; + btn.disabled = true; fetch('/api/validate_chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ chat_id: channelId }) }) .then(r => r.json()).then(d => { - if (d.valid) { showVal(d); validatedChannel = d; doSave(d.chat_title, d.chat_type); } + btn.disabled = false; + if (d.valid) { showVal(d); validatedChannel = d; doAdd(d.chat_title, d.chat_type); } + else { showVal(d); toast('频道验证失败', 'err'); } + }).catch(e => { btn.disabled = false; toast('验证失败: ' + e.message, 'err'); }); + } + } + + function removeFromQueue(idx) { + downloadQueue.splice(idx, 1); + renderQueue(); + } + + function clearQueue() { + if (!downloadQueue.length) return; + if (!confirm('确认清空队列里的 ' + downloadQueue.length + ' 个频道?')) return; + downloadQueue = []; + renderQueue(); + } + + // ── 保存并重启(队列 + 表单一起提交) ── + function saveAndRestart() { + const channelId = parseChannelId(document.getElementById('channel-input').value); + const startDate = document.getElementById('start-date').value; + const endDate = document.getElementById('end-date').value; + + const hasForm = !!channelId; + const hasQueue = downloadQueue.length > 0; + if (!hasForm && !hasQueue) { toast('请输入频道或先加入队列', 'err'); return; } + if (hasForm && !startDate) { toast('请选择开始日期', 'err'); return; } + + const btn = document.getElementById('save-btn'); + btn.disabled = true; btn.innerHTML = ' 重启中…'; + + const submitAll = (formItem) => { + const items = downloadQueue.slice(); + if (formItem && !items.some(q => q.chat_id === formItem.chat_id)) items.push(formItem); + fetch('/api/save_and_restart_multi', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ items }) + }).then(r => r.json()).then(d => { + if (d.success) { + toast('✅ ' + d.message + ',5秒后刷新…'); + setTimeout(() => location.reload(), 5000); + } else { + toast('失败: ' + (d.error||''), 'err'); + btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; + } + }).catch(e => { + toast('操作失败: ' + e.message, 'err'); + btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; + }); + }; + + if (!hasForm) { submitAll(null); return; } + + const buildFormItem = (chatTitle, chatType) => ({ + chat_id: channelId, + chat_title: chatTitle || channelId, + chat_type: chatType || '', + download_filter: buildFilterFromForm(startDate, endDate), + start_date: startDate, + end_date: endDate, + }); + + if (validatedChannel && validatedChannel.chat_id === channelId) { + submitAll(buildFormItem(validatedChannel.chat_title, validatedChannel.chat_type)); + } else { + fetch('/api/validate_chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ chat_id: channelId }) }) + .then(r => r.json()).then(d => { + if (d.valid) { showVal(d); validatedChannel = d; submitAll(buildFormItem(d.chat_title, d.chat_type)); } else { showVal(d); toast('频道验证失败', 'err'); btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; } }).catch(e => { toast('验证失败: ' + e.message, 'err'); btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; }); } diff --git a/module/web.py b/module/web.py index aa311b1..e8218e7 100644 --- a/module/web.py +++ b/module/web.py @@ -646,6 +646,58 @@ def _update_chat_config(chat_id: str, download_filter: str, update_memory: bool return {"success": False, "error": str(e)} +def _update_chat_configs_multi(items: list, update_memory: bool = True) -> dict: + """ + 多频道版本:把 items 数组写入 config["chat"],完整覆盖原数组。 + 每个 item 形如 {"chat_id": "xxx", "download_filter": "xxx"}。 + 同步更新内存里的 _app.chat_download_config。 + """ + global _app + if not items: + return {"success": False, "error": "队列为空"} + + try: + yaml = YAML() + yaml.preserve_quotes = True + with open(CONFIG_FILE_PATH, "r", encoding="utf-8") as f: + config = yaml.load(f) + + # 按队列顺序重建 chat 数组 + new_chat_list = [] + for item in items: + chat_id = str(item.get("chat_id", "")).strip() + download_filter = str(item.get("download_filter", "") or "").strip() + if not chat_id: + continue + entry = {"chat_id": chat_id, "last_read_message_id": 0} + if download_filter: + entry["download_filter"] = download_filter + new_chat_list.append(entry) + + if not new_chat_list: + return {"success": False, "error": "队列中没有有效的频道"} + + config["chat"] = new_chat_list + + with open(CONFIG_FILE_PATH, "w", encoding="utf-8") as f: + yaml.dump(config, f) + + # 同步内存配置 + if update_memory and _app: + _app.config["chat"] = new_chat_list + _app.chat_download_config.clear() + for entry in new_chat_list: + c = ChatDownloadConfig() + c.last_read_message_id = 0 + c.download_filter = entry.get("download_filter", "") + _app.chat_download_config[entry["chat_id"]] = c + + return {"success": True, "count": len(new_chat_list)} + except Exception as e: + logger.exception(f"Error updating multi chat config: {e}") + return {"success": False, "error": str(e)} + + async def _validate_chat(chat_id: str) -> dict: """ Validate chat/channel and get its info. @@ -804,6 +856,62 @@ def api_save_and_restart(): return jsonify({"success": False, "error": str(e)}) +@_flask_app.route("/api/save_and_restart_multi", methods=["POST"]) +@login_required +def api_save_and_restart_multi(): + """多频道版本的保存并重启。接收 items 数组,每项含 chat_id + download_filter。""" + global _app + try: + data = request.get_json() or {} + items = data.get("items", []) + if not isinstance(items, list) or not items: + return jsonify({"success": False, "error": "队列为空"}) + + # 规范化 items:每项必须有 chat_id;download_filter 可由 start_date/end_date 构建 + normalized = [] + for it in items: + cid = str(it.get("chat_id", "") or "").strip() + if not cid: + continue + flt = str(it.get("download_filter", "") or "").strip() + if not flt: + sd = str(it.get("start_date", "") or "").strip() + ed = str(it.get("end_date", "") or "").strip() + if sd: + flt = _build_download_filter(sd, ed) + normalized.append({ + "chat_id": cid, + "download_filter": flt, + "chat_title": it.get("chat_title", "") or "", + "chat_type": it.get("chat_type", "") or "", + }) + + if not normalized: + return jsonify({"success": False, "error": "没有有效的频道"}) + + result = _update_chat_configs_multi(normalized, update_memory=True) + if not result.get("success"): + return jsonify(result) + + # 历史记录更新 + for it in normalized: + _add_to_channel_history(it["chat_id"], it["chat_title"], it["chat_type"]) + + # 触发重启 + if _app: + _app.restart_program = True + logger.info(f"Restart flag set, {len(normalized)} chats queued") + + return jsonify({ + "success": True, + "count": len(normalized), + "message": f"已保存 {len(normalized)} 个频道任务,正在重启..." + }) + except Exception as e: + logger.exception(f"Error in api_save_and_restart_multi: {e}") + return jsonify({"success": False, "error": str(e)}) + + @_flask_app.route("/api/start_download", methods=["POST"]) @login_required def api_start_download():