feat: 多频道任务队列总览卡片
部署到群晖 / deploy (push) Successful in 52s

- download_stat 新增 _task_queue / _completed_chats 与三个辅助函数
- 切频道前自动把上一个频道的最终进度快照进 completed_chats
- save_and_restart(_multi) 启动任务时设置队列,供前端渲染总览
- 前端 banner 改为"任务队列"多卡片列表(已完成 / 🚀当前 / 排队中),
  含进度条、跳过明细;兼容单频道场景

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuming
2026-04-23 12:47:44 +08:00
parent 58194ba29f
commit e5e375c30d
4 changed files with 225 additions and 76 deletions
+3
View File
@@ -18,6 +18,7 @@ from module.download_stat import (
update_download_status,
update_task_progress,
reset_task_progress,
snapshot_current_chat,
increment_task_stat,
get_task_progress,
is_message_skipped,
@@ -602,6 +603,8 @@ async def download_chat_task(
node: TaskNode,
):
"""Download all task"""
# 切到下一个频道前,先把上一个频道的最终进度快照进 _completed_chats
snapshot_current_chat()
# Reset and update task progress
reset_task_progress()
+53
View File
@@ -43,6 +43,12 @@ _task_progress: dict = {
"last_update": 0,
}
# 多频道任务队列与已完成快照(独立于 _task_progress,不被单频道 reset 影响)
# _task_queue: 任务开始时由 API 设置,[{chat_id, chat_title}, ...]
# _completed_chats: 每切换一个频道前把上一个的最终进度快照到这里
_task_queue: list = []
_completed_chats: list = []
def get_download_result() -> dict:
"""get global download result"""
@@ -74,9 +80,56 @@ def get_task_progress() -> dict:
elif progress["skipped_files"] > 0 or progress["checked_messages"] > 0:
progress["is_checking"] = False
# 附加多频道任务总览,供前端渲染队列卡片
progress["task_queue"] = list(_task_queue)
progress["completed_chats"] = list(_completed_chats)
return progress
def snapshot_current_chat():
"""把当前 _task_progress 的核心字段快照进 _completed_chats。current_chat 为空时 no-op。
在多频道串行下载时,每个 download_chat_task 开始前调用,保留上一个频道的最终进度。
"""
global _completed_chats
chat_id = _task_progress.get("current_chat", "")
if not chat_id:
return
qual = _task_progress.get("qualified_files", 0) or 0
est = _task_progress.get("estimated_total", 0) or 0
existing = _task_progress.get("existing_skipped", 0) or 0
raw_total = est or qual
real_total = max(0, raw_total - existing)
_completed_chats.append({
"chat_id": chat_id,
"chat_title": _task_progress.get("current_chat_title", "") or chat_id,
"done": _task_progress.get("completed_files", 0) or 0,
"total": real_total,
"skip": _task_progress.get("skipped_files", 0) or 0,
"existing_skip": existing,
"failed": _task_progress.get("failed_files", 0) or 0,
})
def set_task_queue(items: list):
"""设置本次任务的完整频道队列。items 每项 {chat_id, chat_title}。"""
global _task_queue
_task_queue = []
for it in (items or []):
cid = str(it.get("chat_id", "") or "").strip()
if not cid:
continue
_task_queue.append({
"chat_id": cid,
"chat_title": it.get("chat_title", "") or cid,
})
def clear_completed_chats():
"""新任务启动时清空已完成列表。"""
global _completed_chats
_completed_chats = []
def update_task_progress(
current_chat: str = None,
current_chat_title: str = None,
+152 -72
View File
@@ -195,17 +195,42 @@
.card-hd-badge { font-size: 11px; font-weight: 400; color: var(--muted); }
.card-bd { padding: 12px 14px; }
/* 任务横幅 */
/* 任务队列总览 */
.task-banner {
display: none; padding: 10px 14px;
border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
}
.task-banner.show { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.task-banner-name { font-weight: 600; color: var(--accent); font-size: 13px; }
.task-banner-filter { font-size: 11px; color: var(--muted); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; }
.task-banner-state { font-size: 12px; color: var(--muted); margin-top: 2px; }
.task-banner-left { min-width: 0; flex: 1; }
.task-banner.show { display: block; }
.qbanner-head {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; color: var(--muted); margin-bottom: 8px;
}
.qbanner-title { font-weight: 600; color: var(--text); font-size: 13px; }
.qbanner-summary { font-size: 11px; }
/* 队列卡片(已完成 / 当前 / 排队中) */
.qcard {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 10px; border-radius: 6px;
margin-bottom: 6px;
border: 1px solid transparent;
}
.qcard:last-child { margin-bottom: 0; }
.qcard-icon { font-size: 14px; line-height: 1.5; flex-shrink: 0; }
.qcard-main { flex: 1; min-width: 0; }
.qcard-name { font-weight: 600; font-size: 13px; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.qcard-state { font-size: 11px; color: var(--muted); margin-top: 2px; }
.qcard-prog {
height: 4px; background: rgba(255,255,255,.12);
border-radius: 2px; overflow: hidden; margin-top: 6px;
}
.qcard-prog-fill { height: 100%; width: 0%; background: #4caf50; transition: width .3s; }
.qcard-active { background: rgba(74,144,226,.08); border-color: rgba(74,144,226,.25); }
.qcard-active .qcard-name { color: var(--accent); }
.qcard-done { background: rgba(76,175,80,.07); opacity: .85; }
.qcard-pending { background: rgba(128,128,128,.06); opacity: .6; }
/* 历史频道卡片 */
.hist-head {
@@ -687,9 +712,6 @@
/* 下载项操作按钮加大便于点按 */
.dl-btn { width: 28px; height: 28px; font-size: 13px; }
/* 任务横幅过滤表达式不限宽 */
.task-banner-filter { max-width: none; }
/* 列表高度减小 */
#dl-list, #done-list { height: 220px; }
@@ -980,23 +1002,13 @@
<!-- ════ MAIN CONTENT ════ -->
<div class="main-scroll">
<!-- 当前任务横幅 -->
<!-- 任务队列总览 -->
<div class="task-banner" id="task-banner">
<div class="task-banner-left">
<div class="task-banner-name" id="banner-chat"></div>
<div class="task-banner-filter" id="banner-filter" style="display:none"></div>
<div class="task-banner-state" id="banner-state"></div>
<!-- 进度条:estimated_total > 0 才展示,展示 已完成 X / N -->
<div id="banner-progress"
style="display:none;margin-top:6px;display:flex;align-items:center;gap:8px;">
<div style="flex:1;height:6px;background:rgba(255,255,255,.12);border-radius:3px;overflow:hidden;">
<div id="tbp-fill"
style="height:100%;width:0%;background:#4caf50;transition:width .3s;"></div>
<div class="qbanner-head">
<span class="qbanner-title">任务队列</span>
<span class="qbanner-summary" id="qbanner-summary"></span>
</div>
<div id="tbp-text" style="font-size:11px;color:var(--muted);white-space:nowrap;">0 / 0</div>
</div>
</div>
<div style="font-size:11px;color:var(--muted);flex-shrink:0;">当前任务</div>
<div id="qbanner-list"></div>
</div>
<!-- 下载队列(待处理) -->
@@ -1126,13 +1138,6 @@
return parts.join(' · ');
}
function updateBannerFilter() {
const el = document.getElementById('banner-filter');
const txt = parseFilterDisplay(currentFilter);
if (txt) { el.textContent = txt; el.style.display = ''; }
else { el.textContent = ''; el.style.display = 'none'; }
}
function loadConfig() {
fetch('/api/get_config').then(r => r.json()).then(d => {
const p = d.save_path || '默认路径';
@@ -1140,7 +1145,6 @@
el.textContent = p; el.title = p;
document.getElementById('path-input').value = d.save_path || '';
currentFilter = d.download_filter || '';
updateBannerFilter();
}).catch(() => {});
}
@@ -1448,6 +1452,121 @@
}).catch(() => {}).finally(() => { btn.disabled = false; });
}
// ── 任务队列总览渲染 ──
function renderTaskQueue(p) {
const banner = document.getElementById('task-banner');
const list = document.getElementById('qbanner-list');
const summary = document.getElementById('qbanner-summary');
const queue = Array.isArray(p.task_queue) ? p.task_queue : [];
const completed = Array.isArray(p.completed_chats) ? p.completed_chats : [];
const currentChat = p.current_chat || '';
// 若没有队列也没有当前任务,隐藏整个 banner
if (!queue.length && !currentChat) {
banner.classList.remove('show');
list.innerHTML = '';
summary.textContent = '';
return;
}
banner.classList.add('show');
// completed 按 chat_id 建索引
const completedMap = {};
completed.forEach(c => { completedMap[c.chat_id] = c; });
// 兜底:队列为空但有 current(多频道队列元信息还没写入时),用 current 顶一下
const effectiveQueue = queue.length
? queue
: [{ chat_id: currentChat, chat_title: p.current_chat_title || currentChat }];
const paused = dlState === 'continue';
let doneCount = 0, runningCount = 0;
list.innerHTML = effectiveQueue.map(q => {
if (completedMap[q.chat_id]) {
doneCount++;
return renderDoneCard(q, completedMap[q.chat_id]);
} else if (q.chat_id === currentChat) {
runningCount++;
return renderActiveCard(q, p, paused);
} else {
return renderPendingCard(q);
}
}).join('');
summary.textContent = `${doneCount}/${effectiveQueue.length} 完成`;
}
function renderDoneCard(q, c) {
const title = escapeHtml(q.chat_title || q.chat_id);
const skipStr = (c.existing_skip && c.existing_skip > 0)
? `跳过${c.skip||0},其中本次跳过${c.existing_skip}`
: `跳过${c.skip||0}`;
const totalStr = (c.total && c.total > 0)
? `${c.done||0} / ${c.total} 完成`
: `${c.done||0} 个完成`;
return `
<div class="qcard qcard-done">
<div class="qcard-icon">✅</div>
<div class="qcard-main">
<div class="qcard-name">${title}</div>
<div class="qcard-state">${totalStr}${skipStr}</div>
</div>
</div>`;
}
function renderActiveCard(q, p, paused) {
const title = escapeHtml(q.chat_title || p.current_chat_title || q.chat_id);
const dl = p.downloading_files||0, done = p.completed_files||0, skip = p.skipped_files||0;
const qual = p.qualified_files||0, est = p.estimated_total||0;
const existingSkip = p.existing_skipped || 0;
const rawTotal = est || qual;
const realTotal = Math.max(0, rawTotal - existingSkip);
const doneStr = rawTotal ? `${done} / ${realTotal}` : `${done}`;
const skipStr = existingSkip > 0 ? `跳过${skip},其中本次跳过${existingSkip}` : `跳过${skip}`;
let st = '';
if (p.is_checking && paused) st = `⏸ 已暂停 · 扫描中… (${skipStr})`;
else if (p.is_checking && dl>0) st = `🔍 扫描+下载中 (${dl}个,${doneStr}${skipStr})`;
else if (p.is_checking) st = `🔍 扫描中… (${doneStr}${skipStr})`;
else if (paused) st = `⏸ 已暂停 (${doneStr}${skipStr})`;
else if (dl>0) st = `🚀 下载中 (${dl}个,${doneStr}${skipStr})`;
else if (done>0||skip>0) st = `✅ 完成 (${doneStr}${skipStr})`;
else st = '⏳ 等待开始';
let progBar = '';
if (realTotal > 0) {
const pct = Math.min(100, Math.round(done * 100 / realTotal));
const suffix = (!est && p.is_checking) ? '(扫描中…)' : '';
progBar = `
<div class="qcard-prog"><div class="qcard-prog-fill" style="width:${pct}%"></div></div>
<div class="qcard-state" style="margin-top:3px;">${done} / ${realTotal}${suffix}</div>`;
}
return `
<div class="qcard qcard-active">
<div class="qcard-icon">🚀</div>
<div class="qcard-main">
<div class="qcard-name">${title}</div>
<div class="qcard-state">${st}</div>
${progBar}
</div>
</div>`;
}
function renderPendingCard(q) {
const title = escapeHtml(q.chat_title || q.chat_id);
return `
<div class="qcard qcard-pending">
<div class="qcard-icon">⏳</div>
<div class="qcard-main">
<div class="qcard-name">${title}</div>
<div class="qcard-state">排队中…</div>
</div>
</div>`;
}
// ── 轮询 ──
function poll() {
fetch('/get_download_status').then(r => r.json()).then(d => {
@@ -1457,46 +1576,7 @@
fetch('/api/task_progress').then(r => r.json()).then(p => {
document.getElementById('h-skip').textContent = p.skipped_files || 0;
const banner = document.getElementById('task-banner');
const paused = dlState === 'continue';
if (p.current_chat) {
banner.classList.add('show');
const title = p.current_chat_title || p.current_chat;
document.getElementById('banner-chat').textContent = title;
const dl = p.downloading_files||0, done = p.completed_files||0, skip = p.skipped_files||0;
const qual = p.qualified_files||0, est = p.estimated_total||0;
const existingSkip = p.existing_skipped || 0;
// 分母优先用缓存值;没有就用当次遍历实时累加的 qualified。再扣除"本次任务已跳过",得到真正要下载的数量
const rawTotal = est || qual;
const realTotal = Math.max(0, rawTotal - existingSkip);
const doneStr = rawTotal ? `${done} / ${realTotal}` : `${done}`;
// 跳过文案:有本次跳过时追加说明
const skipStr = existingSkip > 0 ? `跳过${skip},其中本次跳过${existingSkip}` : `跳过${skip}`;
let st = '';
if (p.is_checking && paused) st = `⏸ 已暂停 · 扫描中… (${skipStr})`;
else if (p.is_checking && dl>0) st = `🔍 扫描+下载中 (${dl}个,${doneStr}${skipStr})`;
else if (p.is_checking) st = `🔍 扫描中… (${doneStr}${skipStr})`;
else if (paused) st = `⏸ 已暂停 (${doneStr}${skipStr})`;
else if (dl>0) st = `🚀 下载中 (${dl}个,${doneStr}${skipStr})`;
else if (done>0||skip>0) st = `✅ 完成 (${doneStr}${skipStr})`;
else st = '⏳ 等待开始';
document.getElementById('banner-state').textContent = st;
// 进度条:有总数才显示;扫描中且无缓存时给"扫描中…"后缀
const prog = document.getElementById('banner-progress');
if (realTotal > 0) {
prog.style.display = 'flex';
const pct = Math.min(100, Math.round(done * 100 / realTotal));
document.getElementById('tbp-fill').style.width = pct + '%';
const suffix = (!est && p.is_checking) ? '(扫描中…)' : '';
document.getElementById('tbp-text').textContent = `${done} / ${realTotal}${suffix}`;
} else {
prog.style.display = 'none';
}
} else {
banner.classList.remove('show');
document.getElementById('banner-progress').style.display = 'none';
}
renderTaskQueue(p);
}).catch(() => {});
fetch('/get_download_list?already_down=false').then(r => r.json()).then(data => {
+13
View File
@@ -19,6 +19,8 @@ import utils
from module.app import Application, ChatDownloadConfig, TaskNode
from module.download_stat import (
DownloadState,
clear_completed_chats,
set_task_queue,
get_download_result,
get_download_state,
get_total_download_speed,
@@ -841,6 +843,10 @@ def api_save_and_restart():
# Add to channel history
_add_to_channel_history(chat_id, chat_title, chat_type)
# 重置队列总览元信息(单频道场景队列就一项)
clear_completed_chats()
set_task_queue([{"chat_id": chat_id, "chat_title": chat_title or chat_id}])
# Trigger restart
if _app:
_app.restart_program = True
@@ -897,6 +903,13 @@ def api_save_and_restart_multi():
for it in normalized:
_add_to_channel_history(it["chat_id"], it["chat_title"], it["chat_type"])
# 重置并设置本次任务的完整队列,供前端渲染"任务队列"卡片
clear_completed_chats()
set_task_queue([
{"chat_id": it["chat_id"], "chat_title": it["chat_title"] or it["chat_id"]}
for it in normalized
])
# 触发重启
if _app:
_app.restart_program = True