Skip to content

Commit 7d37b0e

Browse files
committed
fix: 修复批量下载完成判断与资源勾选同步
- 提前登记整批下载任务,使用线程池并确保完成通知只触发一次 - 为共享 HTTP 会话设置默认超时,覆盖解析、目录及封面请求 - 保留搜索重建后的图片引用,避免封面与复选框消失 - 将分类勾选限定在当前筛选结果内 - 同步网址编辑与勾选状态,支持粘贴、删除和撤销 - 消除进度测试对执行顺序的依赖,补充下载、超时及界面回归测试
1 parent baf3ba3 commit 7d37b0e

7 files changed

Lines changed: 469 additions & 60 deletions

File tree

src/tchmaterial_parser/network.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@
88

99
import requests
1010

11-
session = requests.Session() # 初始化请求
12-
session.trust_env = False # 不读取系统或环境变量中的代理配置
13-
1411
REQUEST_TIMEOUT = (10, 60) # 连接 / 相邻两次收数据的超时秒数;requests 没有全局默认超时,缺失时卡住的请求会永久挂起
1512

13+
class TimeoutSession(requests.Session):
14+
def request(self, method: str, url: str, **kwargs) -> requests.Response:
15+
kwargs.setdefault("timeout", REQUEST_TIMEOUT)
16+
return super().request(method, url, **kwargs)
17+
18+
session = TimeoutSession() # 详情、目录、音频与封面请求统一使用默认超时
19+
session.trust_env = False # 不读取系统或环境变量中的代理配置
20+
1621
headers = { # 设置请求头部,包含认证信息
1722
"Authorization": "Bearer 0",
1823
"Origin": "https://basic.smartedu.cn",

src/tchmaterial_parser/ui/download_panel.py

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import tkinter as tk
77
from collections import Counter
88
from collections.abc import Callable
9+
from concurrent.futures import ThreadPoolExecutor
910
from tkinter import ttk, messagebox, filedialog
1011
from urllib.parse import urlsplit, urlunsplit
1112
from xml.etree import ElementTree
@@ -228,7 +229,7 @@ def show_parse_progress(current: int, total: int) -> None: # 后台解析大量
228229
ui_call(progress_label.config, text=f"正在解析链接 {current}/{total}")
229230

230231
def refresh_download_progress() -> None: # 汇总全部任务状态刷新进度条与标签,没有 Content-Length 或出现失败时也能看到进展
231-
states = list(download_states) # 下载线程会并发追加状态,先取快照避免遍历时被修改
232+
states = list(download_states)
232233
all_downloaded_size = sum(state["downloaded_size"] for state in states)
233234
all_total_size = sum(state["total_size"] for state in states)
234235
finished_number = len([state for state in states if state["finished"]])
@@ -368,19 +369,56 @@ def restore_download_btn() -> None: # 未产生下载任务时恢复界面状态
368369
messagebox.showwarning("警告", "以下 “行” 无法解析:\n" + "\n".join(failed_urls)) # 显示警告对话框
369370
return
370371

371-
for resource, save_path in download_targets:
372-
thread_it(download_file, resource.url, save_path, resource.chapters) # 开始下载(多线程,防止窗口卡死)
373-
374372
progress_label.config(text=f"正在下载 {len(download_targets)} 个文件")
373+
directory = dir_path if len(resources_info_list) > 1 else os.path.dirname(download_targets[0][1])
374+
start_download_batch(download_targets, directory)
375375

376376
if failed_urls:
377377
messagebox.showwarning("警告", "以下 “行” 无法解析:\n" + "\n".join(failed_urls)) # 显示警告对话框
378378

379379
parse_urls_in_background(list(urls), bookmark_var.get(), start_downloads)
380380

381-
def download_file(url: str, save_path: str, chapters: list[dict] | None = None) -> None: # 下载文件
382-
current_state = { "download_url": url, "save_path": save_path, "downloaded_size": 0, "total_size": 0, "finished": False, "failed_reason": None }
383-
download_states.append(current_state)
381+
def create_download_state(url: str, save_path: str) -> dict:
382+
return { "download_url": url, "save_path": save_path, "downloaded_size": 0, "total_size": 0, "finished": False, "failed_reason": None }
383+
384+
def start_download_batch(targets: list[tuple[ResourceInfo, str]], directory: str) -> None:
385+
global download_states
386+
# 所有排队任务先登记,快速失败或完成的线程也不会漏算尚未启动的任务。
387+
states = [create_download_state(resource.url, save_path) for resource, save_path in targets]
388+
download_states = states
389+
390+
def worker() -> None:
391+
# 批量勾选可能产生数千个文件,仅保留少量工作线程,其余任务排队。
392+
with ThreadPoolExecutor(max_workers=3) as executor:
393+
futures = [
394+
executor.submit(download_file, resource.url, save_path, resource.chapters, state)
395+
for (resource, save_path), state in zip(targets, states)
396+
]
397+
for future in futures:
398+
future.result()
399+
ui_call(finish_download_batch, states, directory) # 全部线程退出后,仅由批次通知一次
400+
401+
thread_it(worker)
402+
403+
def finish_download_batch(states: list[dict], directory: str) -> None: # 在主线程统一恢复控件并显示整批结果
404+
download_progress_bar.config(value=0)
405+
progress_label.config(text="等待下载")
406+
download_btn.config(state="normal")
407+
408+
failed_states = [state for state in states if state["failed_reason"]]
409+
if failed_states:
410+
failed_message = "\n\n".join(
411+
f"{os.path.relpath(state['save_path'], directory)}\n{state['failed_reason']}"
412+
for state in failed_states
413+
)
414+
messagebox.showwarning("下载完成", f"文件已下载到:{directory}\n以下文件下载失败:\n{failed_message}")
415+
else:
416+
messagebox.showinfo("下载完成", f"文件已下载到:{directory}")
417+
418+
def download_file(url: str, save_path: str, chapters: list[dict] | None = None, current_state: dict | None = None) -> None: # 下载文件
419+
if current_state is None: # 保留单独下载文件的调用方式
420+
current_state = create_download_state(url, save_path)
421+
download_states.append(current_state)
384422
temp_path = f"{save_path}.tmp"
385423

386424
response = None
@@ -389,7 +427,6 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
389427
response, attempted_urls = request_download(url)
390428

391429
if not response.ok: # 服务器返回表示错误的 HTTP 状态码
392-
current_state["finished"] = True
393430
current_state["failed_reason"] = download_failure_reason(response, attempted_urls)
394431
else:
395432
current_state["total_size"] = int(response.headers.get("Content-Length", 0))
@@ -407,7 +444,6 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
407444
if current_state["total_size"] > 0 and current_state["downloaded_size"] != current_state["total_size"]: # 文件下载不完整
408445
current_state["failed_reason"] = f"文件下载不完整,需下载 {current_state['total_size']} 字节,实际下载 {current_state['downloaded_size']} 字节"
409446
current_state["downloaded_size"], current_state["total_size"] = 0, 0
410-
current_state["finished"] = True
411447
try:
412448
os.remove(temp_path)
413449
except Exception:
@@ -418,12 +454,10 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
418454
add_bookmarks(temp_path, chapters)
419455

420456
os.replace(temp_path, save_path) # 重命名临时文件为目标文件
421-
current_state["finished"] = True
422457

423458
except Exception as e:
424459
print_error(e)
425460
current_state["downloaded_size"], current_state["total_size"] = 0, 0
426-
current_state["finished"] = True
427461
current_state["failed_reason"] = redact_access_token(traceback.format_exc().rstrip())
428462
try:
429463
os.remove(temp_path)
@@ -432,28 +466,10 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
432466
finally:
433467
if response is not None:
434468
response.close()
469+
current_state["finished"] = True
435470

436471
refresh_download_progress() # 每个任务结束时刷新一次,重试等待期间也能看到完成数与失败数
437472

438-
if all(state["finished"] for state in download_states): # 所有文件下载完成
439-
ui_call(download_progress_bar.config, value=0) # 重置进度条
440-
ui_call(progress_label.config, text="等待下载") # 清空进度标签
441-
ui_call(download_btn.config, state="normal") # 设置下载按钮为启用状态
442-
443-
failed_states = [state for state in download_states if state["failed_reason"]]
444-
if failed_states: # 存在下载失败的文件
445-
failed_message = "\n\n".join(
446-
f"{os.path.basename(state['save_path'])}\n{state['failed_reason']}"
447-
for state in failed_states
448-
)
449-
ui_call(
450-
messagebox.showwarning,
451-
"下载完成",
452-
f"文件已下载到:{os.path.dirname(save_path)}\n以下文件下载失败:\n{failed_message}",
453-
)
454-
else:
455-
ui_call(messagebox.showinfo, "下载完成", f"文件已下载到:{os.path.dirname(save_path)}")
456-
457473
def format_bytes(size: float) -> str: # 将数据单位进行格式化,返回以 KB、MB、GB、TB、PB 为单位的数据大小
458474
for x in ["字节", "KB", "MB", "GB", "TB"]:
459475
if size < 1024.0:

src/tchmaterial_parser/ui/resource_tree.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def build_resource_tree(pane: ttk.Frame, resource_list: dict[str, dict], url_tex
130130
tree_preview_images: dict[str, ImageTk.PhotoImage] = {} # 缓存大尺寸封面,用于悬停预览
131131
loading_tree_images: set[str] = set()
132132
checked_items: set[str] = set() # 已勾选末级资源的树项路径,搜索重建树视图后仍保留
133+
leaf_urls = {item_id: build_resource_url(item_id, data) for item_id, data in iter_leaf_resources(resource_list)}
133134
checkbox_pils: dict[str, Image.Image] = {} # 三态复选框底图,跟随主题配色重建
134135
checkbox_icons: dict[str, ImageTk.PhotoImage] = {} # 无封面树项直接使用的复选框图标(已含右侧间距)
135136
tree_font = tkfont.nametofont("AppBodyFont")
@@ -151,12 +152,13 @@ def build_tree_items(parent: str, items: dict[str, dict], parent_names: tuple[st
151152
path_names = (*parent_names, display_name)
152153
tree_item_data[item_id] = option_data
153154
tree_item_paths[item_id] = path_names
155+
tree_item_images[item_id] = compose_item_image(item_id)
154156
treeview.insert(
155157
parent,
156158
"end",
157159
iid=item_id,
158160
text=display_name,
159-
image=compose_item_image(item_id),
161+
image=tree_item_images[item_id],
160162
open=expand_all or not parent,
161163
)
162164
children: dict[str, dict] = option_data.get("children", {})
@@ -186,7 +188,7 @@ def on_theme_changed() -> None: # 主题切换后重建复选框配色并刷新
186188
refresh_item_image(item_id)
187189

188190
def item_check_state(item_id: str) -> str: # 末级资源为勾选/未勾选两态,分类按后代整体勾选情况显示三态
189-
node = find_tree_node(resource_list, item_id)
191+
node = tree_item_data.get(item_id) or find_tree_node(resource_list, item_id)
190192
if node is None:
191193
return "unchecked"
192194
children = node.get("children")
@@ -273,7 +275,7 @@ def refresh_resource_tree() -> None: # 根据搜索词重建树视图
273275
ui_call(load_visible_tree_icons)
274276

275277
def insert_resource_urls(urls: list[str]) -> None: # 将链接追加到 URL 输入框,跳过已存在的行
276-
existing_lines = set(url_text.get("1.0", "end").splitlines())
278+
existing_lines = {line.strip() for line in url_text.get("1.0", "end").splitlines()}
277279
new_urls = [url for url in dict.fromkeys(urls) if url and url not in existing_lines] # 保序去重,并跳过已存在的链接
278280
if not new_urls:
279281
return
@@ -289,7 +291,7 @@ def remove_resource_urls(urls: list[str]) -> None: # 从 URL 输入框移除已
289291
return
290292
url_set = set(urls)
291293
lines = url_text.get("1.0", "end").splitlines()
292-
kept_lines = [line for line in lines if line not in url_set]
294+
kept_lines = [line for line in lines if line.strip() not in url_set]
293295
if len(kept_lines) == len(lines):
294296
return
295297
url_text.delete("1.0", "end")
@@ -300,7 +302,8 @@ def update_checked_count() -> None: # 更新已勾选教材数量提示
300302
checked_count_label.config(text=f"已选 {len(checked_items)} 项" if checked_items else "")
301303

302304
def toggle_item(item_id: str) -> None: # 切换树项勾选状态:分类按三态决定目标状态并级联其下所有末级资源
303-
node = find_tree_node(resource_list, item_id)
305+
sync_checked_items() # 文本修改事件尚未处理时,也以最新输入为准
306+
node = tree_item_data.get(item_id) # 搜索时只操作当前筛选出的子树
304307
if node is None:
305308
return
306309
children = node.get("children")
@@ -313,33 +316,37 @@ def toggle_item(item_id: str) -> None: # 切换树项勾选状态:分类按三
313316
set_items_checked(leafs, checked)
314317

315318
def set_items_checked(leafs: list[tuple[str, dict]], checked: bool) -> None: # 批量更新末级资源勾选状态,级联刷新图标并同步 URL 输入框
316-
changed_leafs: list[tuple[str, dict]] = []
317-
for leaf_id, leaf_data in leafs:
318-
if checked == (leaf_id in checked_items):
319-
continue
320-
if checked:
321-
checked_items.add(leaf_id)
322-
else:
323-
checked_items.discard(leaf_id)
324-
changed_leafs.append((leaf_id, leaf_data))
325-
if not changed_leafs:
319+
urls = [leaf_urls[leaf_id] for leaf_id, _leaf_data in leafs]
320+
if checked:
321+
insert_resource_urls(urls)
322+
else:
323+
remove_resource_urls(urls)
324+
sync_checked_items()
325+
326+
def sync_checked_items() -> None: # 粘贴、删除、撤销以及树项操作统一以输入框中的链接为准
327+
urls = {line.strip() for line in url_text.get("1.0", "end").splitlines()}
328+
new_checked_items = {item_id for item_id, url in leaf_urls.items() if url in urls}
329+
changed_ids = checked_items.symmetric_difference(new_checked_items)
330+
if not changed_ids:
326331
return
332+
checked_items.clear()
333+
checked_items.update(new_checked_items)
327334

328335
refresh_ids: set[str] = set() # 状态变化的末级资源及其各级祖先分类都需要刷新图标
329-
for leaf_id, _leaf_data in changed_leafs:
336+
for leaf_id in changed_ids:
330337
segments = leaf_id.split(":")
331338
refresh_ids.update(":".join(segments[:index]) for index in range(1, len(segments) + 1))
332339
for refresh_id in refresh_ids:
333340
if refresh_id in tree_item_data:
334341
refresh_item_image(refresh_id)
335342

336-
urls = [build_resource_url(leaf_id, leaf_data) for leaf_id, leaf_data in changed_leafs]
337-
if checked:
338-
insert_resource_urls(urls)
339-
else:
340-
remove_resource_urls(urls)
341343
update_checked_count()
342344

345+
def on_urls_modified(_event: tk.Event) -> None:
346+
if url_text.edit_modified():
347+
url_text.edit_modified(False)
348+
sync_checked_items()
349+
343350
def on_tree_press(event: tk.Event) -> str | None: # 按下鼠标时隐藏悬停提示;左键点击标题或封面(含复选框)时切换勾选,点击箭头或缩进保持展开收起
344351
hide_tree_tooltip()
345352
if event.num != 1 or treeview.identify("element", event.x, event.y) not in ("text", "image"):
@@ -476,6 +483,9 @@ def on_tree_shift_mousewheel(event: tk.Event) -> str:
476483
theme.on_theme_applied(on_theme_changed) # 主题切换后重建复选框配色并刷新树项图标
477484
update_checked_count()
478485
refresh_resource_tree() # 初始展示完整资源树并展开一级目录
486+
sync_checked_items()
487+
url_text.edit_modified(False)
488+
url_text.bind("<<Modified>>", on_urls_modified, add="+")
479489
search_var.trace_add("write", schedule_search)
480490
treeview.configure(yscrollcommand=on_tree_view_change)
481491
treeview.bind("<space>", on_tree_space)

0 commit comments

Comments
 (0)