feat: 支持多频道队列下载
部署到群晖 / deploy (push) Successful in 51s

This commit is contained in:
yuming
2026-04-23 09:56:40 +08:00
parent 2f70a6627e
commit a1499a431f
2 changed files with 294 additions and 27 deletions
+186 -27
View File
@@ -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 @@
<!-- 提交 -->
<div class="fb-field fb-field-submit">
<button class="btn-submit" id="save-btn" onclick="saveAndRestart()">
🔄 保存并重启下载
</button>
<div class="submit-row">
<button class="btn-add-queue" id="add-queue-btn" onclick="addToQueue()">
加入队列
</button>
<button class="btn-submit" id="save-btn" onclick="saveAndRestart()" style="flex:1">
🔄 保存并重启下载
</button>
</div>
</div>
</div>
@@ -958,6 +999,18 @@
<div style="font-size:11px;color:var(--muted);flex-shrink:0;">当前任务</div>
</div>
<!-- 下载队列(待处理) -->
<div class="card" id="queue-card" style="display:none">
<div class="card-hd">
<span>
待处理队列
<span class="card-hd-badge" id="queue-count" style="margin-left:6px">0 个</span>
</span>
<button class="btn-clear" onclick="clearQueue()">清空</button>
</div>
<div class="card-bd" id="queue-list"></div>
</div>
<!-- 历史频道 -->
<div class="card">
<div class="hist-head" onclick="toggleHist()">
@@ -1211,41 +1264,147 @@
});
});
// ── 保存并重启 ──
function saveAndRestart() {
// ── 下载队列 ──
let downloadQueue = [];
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
})[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) => `
<div class="queue-item">
<div class="queue-num">${i + 1}</div>
<div class="queue-info">
<div class="queue-name">${escapeHtml(q.chat_title || q.chat_id)}</div>
<div class="queue-filter" title="${escapeHtml(q.download_filter || '')}">${escapeHtml(q.download_filter || '无过滤条件')}</div>
</div>
<button class="queue-del" onclick="removeFromQueue(${i})" title="移除">✕</button>
</div>
`).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 = '<span class="spin"></span> 重启中…';
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 = '<span class="spin"></span> 验证中…';
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 = '<span class="spin"></span> 重启中…';
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 = '🔄 保存并重启下载'; });
}
+108
View File
@@ -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_iddownload_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():