perf(app): 优化图形用户交互界面

This commit is contained in:
Quan
2026-08-08 21:55:34 +08:00
parent 514bcdd1fc
commit 9541c11d37
7 changed files with 211 additions and 90 deletions
+38 -34
View File
@@ -1,13 +1,11 @@
"""PyWebView 静态界面与下载器核心之间的运行时桥接层。"""
from __future__ import annotations
import asyncio
import sys
from asyncio import CancelledError
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from re import search
@@ -15,6 +13,7 @@ from threading import Event, Thread
from typing import Any
from uuid import uuid4
import webview
from pyperclip import copy, paste
from ..application import XHS
@@ -43,7 +42,7 @@ HISTORY_PAGE_SIZE = 100
def now_text() -> str:
"""返回用于界面显示的本地时间。"""
return datetime.now().strftime("%H:%M:%S")
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def build_update_result(release_url: str) -> dict[str, Any]:
@@ -52,7 +51,10 @@ def build_update_result(release_url: str) -> dict[str, Any]:
tag = release_url.rstrip("/").split("/")[-1]
match = search(r"(?<!\d)(\d+)\.(\d+)(?!\d)", tag)
if not match:
raise ValueError("Invalid release version")
return {
"status": "error",
"message": _("无法解析版本号:{0}").format(tag),
}
target = tuple(map(int, match.groups()))
current = (VERSION_MAJOR, VERSION_MINOR)
@@ -84,13 +86,16 @@ def build_update_result(release_url: str) -> dict[str, Any]:
title = _("当前已是最新正式版")
message = _("当前版本为 {0}").format(current_version)
case 2:
kind = "development_current" if VERSION_BETA else "up_to_date"
title = _("当前已是最新开发版") if VERSION_BETA else _("当前已是最新正式版")
kind = "development_current"
title = _("当前已是最新开发版")
message = _("当前版本为 {0},最新正式版为 {1}").format(
current_version, latest_version
)
case _:
raise ValueError
return {
"status": "error",
"message": _("版本比较结果无效"),
}
return {
"status": "ok",
@@ -108,10 +113,6 @@ class TaskState:
url: str
source: str = "manual"
state: str = "pending"
error: str | None = None
created_at: str = field(default_factory=now_text)
started_at: str | None = None
finished_at: str | None = None
def as_dict(self) -> dict[str, Any]:
"""转换为可通过 PyWebView 序列化给 JavaScript 的普通字典。"""
@@ -121,10 +122,6 @@ class TaskState:
"url": self.url,
"source": self.source,
"state": self.state,
"error": self.error,
"created_at": self.created_at,
"started_at": self.started_at,
"finished_at": self.finished_at,
}
@@ -175,7 +172,7 @@ class GuiBackend:
"created": 0,
}
def start(self) -> None:
def start(self, wait: bool = True) -> None:
# PyWebView 的 JavaScript 调用来自主线程,下载器事件循环运行于后台线程,
# 以避免网络请求阻塞窗口,并通过 call() 实现跨线程协程调度。
self.thread = Thread(
@@ -184,7 +181,15 @@ class GuiBackend:
daemon=True,
)
self.thread.start()
self.ready.wait()
if not wait:
return
self.wait_until_ready()
def wait_until_ready(self, timeout: float | None = None) -> None:
"""等待 GUI 后端线程完成初始化,以便处理接口调用。"""
if not self.ready.wait(timeout):
raise RuntimeError(_("GUI 后端初始化超时"))
if self.start_error:
raise RuntimeError(_("GUI 后端初始化失败")) from self.start_error
@@ -220,7 +225,6 @@ class GuiBackend:
self.task_queue = asyncio.Queue()
await self._create_xhs()
self.worker = asyncio.create_task(self._worker_loop())
self.add_log(_("GUI 后端初始化完成"), "success")
async def _create_xhs(self) -> None:
"""按当前配置创建 XHS 实例,并把核心日志导向 GUI。"""
@@ -266,9 +270,19 @@ class GuiBackend:
def call(self, coroutine):
"""把协程提交到后台循环,并同步等待 PyWebView API 的返回值。"""
if not self.loop or self.closed:
raise RuntimeError(_("GUI 后端未运行"))
return asyncio.run_coroutine_threadsafe(coroutine, self.loop).result(timeout=30)
try:
if self.closed:
raise RuntimeError(_("GUI 后端未运行"))
self.wait_until_ready(timeout=30)
if not self.loop:
raise RuntimeError(_("GUI 后端未运行"))
return asyncio.run_coroutine_threadsafe(coroutine, self.loop).result(
timeout=30
)
except Exception:
with suppress(Exception):
coroutine.close()
raise
def add_log(self, message: str, level: str = "info") -> None:
"""追加一条有上限的运行日志,避免长期运行时内存无限增长。"""
@@ -332,10 +346,7 @@ class GuiBackend:
if task.state != "pending":
return
task.state = "processing"
task.started_at = now_text()
try:
if not self.xhs:
raise RuntimeError(_("下载器尚未初始化"))
# extract() 通过回调返回汇总统计,GUI 不根据 URL 推断任务结果。
statistics: dict[str, Any] = {}
@@ -356,8 +367,6 @@ class GuiBackend:
task.state = "skipped"
else:
task.state = "failed"
if task.state == "failed":
task.error = _("作品处理失败")
if self.xhs.manager.download_record:
self.history_revision += 1
except CancelledError:
@@ -366,10 +375,7 @@ class GuiBackend:
raise
except Exception as error:
task.state = "failed"
task.error = str(error)
self.add_log(task.error, "error")
finally:
task.finished_at = now_text()
self.add_log(str(error), "error")
def on_file_progress(self, event: dict[str, Any]) -> None:
"""接收下载器的实时文件事件,按“任务 ID + 文件名”更新进度。"""
@@ -407,7 +413,6 @@ class GuiBackend:
if not task or task.state != "pending":
return False
task.state = "cancelled"
task.finished_at = now_text()
return True
async def clear_finished(self) -> int:
@@ -485,7 +490,7 @@ class GuiBackend:
requested_page = max(1, int(page))
except (TypeError, ValueError):
requested_page = 1
if not enabled or not self.xhs:
if not enabled:
return {
"enabled": False,
"items": [],
@@ -734,7 +739,6 @@ class GuiApi:
if not self._window:
raise RuntimeError(_("GUI 窗口尚未就绪"))
import webview
initial = Path(str(directory or ""))
dialog_directory = str(initial) if initial.is_dir() else ""
+9 -15
View File
@@ -3,6 +3,8 @@
import sys
from pathlib import Path
import webview
from ..module import PROJECT, ROOT
from .backend import GuiApi, GuiBackend
@@ -11,10 +13,10 @@ INDEX_PATH = ROOT.joinpath("static", "GUI", "index.html")
def get_index_path() -> Path:
"""返回 GUI 入口 HTML 的绝对路径,并在文件缺失时尽早报错。"""
"""返回 GUI 入口页面的绝对路径,并在文件缺失时尽早报错。"""
if not INDEX_PATH.is_file():
raise FileNotFoundError(f"GUI entry document was not found: {INDEX_PATH}")
raise FileNotFoundError(f"GUI 入口文件不存在:{INDEX_PATH}")
return INDEX_PATH
@@ -28,28 +30,19 @@ def get_icon_path(platform_name: str | None = None) -> Path:
}.get(platform_name, ".png")
icon_path = ROOT.joinpath("static", f"XHS-Downloader{suffix}")
if not icon_path.is_file():
raise FileNotFoundError(f"GUI icon was not found: {icon_path}")
raise FileNotFoundError(f"GUI 图标文件不存在:{icon_path}")
return icon_path
def launch() -> None:
"""创建并启动静态 GUI 窗口;窗口关闭后再释放后台下载器。"""
try:
import webview
except ImportError as error:
raise RuntimeError(
"PyWebView is required to launch the GUI. "
"Install project dependencies first (for example: pip install -e .)."
) from error
# 先启动后端,确保窗口加载完成后 JavaScript 可以立即调用 API。
# 先创建窗口,再异步启动后端,让启动加载层尽早显示。
backend = GuiBackend()
backend.start()
index_url = get_index_path().as_uri()
icon_path = get_icon_path()
api = GuiApi(backend)
# 1280x720 为桌面端默认及最小尺寸,前端 CSS 负责更窄窗口的响应式布局。
# 1280x720 为桌面端默认及最小尺寸,前端样式负责更窄窗口的响应式布局。
window = webview.create_window(
PROJECT,
index_url,
@@ -61,6 +54,7 @@ def launch() -> None:
js_api=api,
)
api._bind_window(window)
backend.start(wait=False)
try:
webview.start(
icon=str(icon_path),
@@ -70,7 +64,7 @@ def launch() -> None:
def main() -> None:
"""``python -m source.GUI.launcher`` 使用的命令行入口。"""
"""命令行入口。"""
launch()
+1 -1
View File
@@ -170,7 +170,7 @@ def get_ui_messages() -> dict[str, str]:
"toast.folder_unavailable": _("无法打开下载文件夹"),
"toast.language_load_failed": _("语言文件加载失败"),
"toast.operation_failed": _("操作失败"),
"toast.no_supported_link": _("未识别到支持的作品链接"),
"toast.no_supported_link": _("提取小红书作品链接失败"),
"name.drag_disable": _("拖动调整顺序,点击停用"),
"name.drag_enable": _("拖动或点击启用"),
}
+1 -1
View File
@@ -64,7 +64,7 @@ def compare_versions(
return 4
if target == current:
return 3 if is_development else 1
return 2
return 2 if is_development else 1
def get_wait_time(
+67 -16
View File
@@ -170,8 +170,31 @@
return hasContent;
}
function insertTextAtCursor(input, text) {
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.value = `${input.value.slice(0, start)}${text}${input.value.slice(end)}`;
const cursor = start + text.length;
input.setSelectionRange(cursor, cursor);
updateCreateButton();
}
urlInput.addEventListener("input", updateCreateButton);
urlInput.addEventListener("keydown", (event) => {
if (!(event.key?.toLowerCase() === "v" && (event.ctrlKey || event.metaKey))) return;
const previousValue = urlInput.value;
window.setTimeout(() => {
if (urlInput.value !== previousValue) return;
void runNativeAction(async () => {
const content = await nativeApi.paste_content();
if (!content) return;
insertTextAtCursor(urlInput, content);
urlInput.focus();
});
});
});
document.getElementById("clearInput").addEventListener("click", () => {
urlInput.value = "";
updateCreateButton();
@@ -180,8 +203,8 @@
document.getElementById("pasteButton").addEventListener("click", () => {
void runNativeAction(async () => {
urlInput.value = await nativeApi.paste_content();
updateCreateButton();
const content = await nativeApi.paste_content();
if (content) insertTextAtCursor(urlInput, content);
urlInput.focus();
});
});
@@ -212,6 +235,28 @@
const fileDownloadList = document.getElementById("fileDownloadList");
let currentQueueFilter = "all";
const listScrollStates = new WeakMap();
function isNearBottom(target, threshold = 2) {
return target.scrollHeight - target.scrollTop - target.clientHeight <= threshold;
}
function followsBottom(target) {
let state = listScrollStates.get(target);
if (state) return state.followBottom;
state = {followBottom: true};
target.addEventListener("scroll", () => {
state.followBottom = isNearBottom(target);
});
listScrollStates.set(target, state);
return state.followBottom;
}
function stickToBottom(target, enabled) {
if (!enabled) return;
target.scrollTop = target.scrollHeight;
}
function matchesQueueFilter(state, filter = currentQueueFilter) {
// 筛选仅修改当前视图的 hidden 状态,不会从后端任务集合中删除任务。
if (filter === "all") return true;
@@ -681,11 +726,9 @@
}
function formatBytes(value) {
// 文件进度使用可读单位显示;总大小未知时由调用方显示“未知大小”。
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
// 文件进度固定使用 MB;总大小未知时由调用方显示“未知大小”。
if (!Number.isFinite(value) || value <= 0) return "0.00MB";
return `${(value / (1024 ** 2)).toFixed(2)} MB`;
}
function createNativeQueueItem(task) {
@@ -709,11 +752,6 @@
state.className = `status-text ${statusClass(task.state)}`;
state.textContent = translateText(statusLabels[task.state]);
stateLine.append(state);
if (task.error) {
const message = document.createElement("span");
message.textContent = task.error;
stateLine.append(message);
}
detail.append(link, stateLine);
item.append(symbol, detail);
if (task.state === "pending") {
@@ -753,6 +791,7 @@
function renderNativeFiles(target, files) {
// 每个文件独立显示名称、已完成大小、总大小和实时进度条;列表容器负责滚动。
const shouldStickToBottom = followsBottom(target);
target.replaceChildren();
if (!files.length) {
const empty = document.createElement("div");
@@ -768,14 +807,18 @@
const name = document.createElement("strong");
name.textContent = file.filename;
name.title = file.filename;
const meta = document.createElement("div");
meta.className = "file-download-meta";
const size = document.createElement("span");
const completed = formatBytes(Number(file.completed_bytes));
const total = file.total_bytes ? formatBytes(Number(file.total_bytes)) :
translateText("download.unknown_size");
const percent = file.total_bytes ? Math.min(100, (file.completed_bytes / file.total_bytes) * 100) : 0;
const progress = file.total_bytes ? ` · ${Math.round(percent)}%` : "";
size.textContent = `${completed} / ${total}${progress}`;
head.append(name, size);
const progress = document.createElement("span");
size.textContent = file.total_bytes ? `${completed}/${total}` : completed;
progress.textContent = file.total_bytes ? `${Math.round(percent)}%` : "";
meta.append(size, progress);
head.append(name, meta);
const line = document.createElement("div");
line.className = `progress-line file ${file.state === "completed" ? "completed" : ""}`;
const fill = document.createElement("i");
@@ -786,13 +829,17 @@
});
const count = target.closest(".file-download-section")?.querySelector(".section-heading span");
if (count) count.textContent = `${files.length} ${translateText("unit.file")}`;
stickToBottom(target, shouldStickToBottom);
}
function renderNativeTaskLists(tasks, files) {
// 使用一次状态快照同时刷新主队列、监听队列及两个文件下载区域。
const visibleTasks = tasks.filter((task) => task.state !== "cancelled");
const queueShouldStickToBottom = followsBottom(queueList);
const monitorShouldStickToBottom = followsBottom(monitorEvents);
queueList.replaceChildren(...visibleTasks.map(createNativeQueueItem));
const dashboardTasks = document.getElementById("dashboardTasks");
const dashboardShouldStickToBottom = followsBottom(dashboardTasks);
if (visibleTasks.length) {
dashboardTasks.replaceChildren(...visibleTasks.map(createNativeCompactTask));
} else {
@@ -801,6 +848,7 @@
empty.textContent = translateText("task.queue_empty");
dashboardTasks.replaceChildren(empty);
}
stickToBottom(dashboardTasks, dashboardShouldStickToBottom);
document.getElementById("dashboardTaskCount").textContent = `${visibleTasks.length} ${translateText(
"unit.item")}`;
const monitorTasks = visibleTasks.filter((task) => task.source === "monitor");
@@ -810,6 +858,8 @@
renderNativeFiles(monitorFileDownloadList, files.filter((file) => monitorIds.has(file.task_id)));
refreshQueue();
refreshMonitorQueue();
stickToBottom(queueList, queueShouldStickToBottom && !queueList.hidden);
stickToBottom(monitorEvents, monitorShouldStickToBottom && !monitorEvents.hidden);
}
function renderNativeLogs(logs) {
@@ -1108,7 +1158,8 @@
showToast(translateText("update.failed"), "warning");
return;
}
const updateAvailable = ["update_available", "stable_available"].includes(result.kind);
const updateAvailable = ["update_available", "stable_available", "development_current"].includes(
result.kind);
const tone = updateAvailable ? "warning" : "success";
const title = result.title;
setUpdateResult(`${title}: ${result.message}`, tone);
+54
View File
@@ -5,6 +5,60 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>XHS-Downloader</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="preload" as="style" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/400.css">
<link rel="preload" as="style" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/600.css">
<link rel="preload" as="style" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/700.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/400.css" media="print"
onload="this.media='all'">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/600.css" media="print"
onload="this.media='all'">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/700.css" media="print"
onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/400.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/600.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.3.0/700.css">
</noscript>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #f1f3f6;
}
.startup-screen {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: #f1f3f6;
}
.startup-content {
display: grid;
justify-items: center;
}
.startup-spinner {
width: 72px;
height: 72px;
border: 5px solid #d4c3c9;
border-top-color: #e13b43;
border-radius: 50%;
animation: startup-spin 0.8s linear infinite;
}
@keyframes startup-spin {
to {
transform: rotate(360deg);
}
}
</style>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
+41 -23
View File
@@ -20,7 +20,8 @@
--amber: #ac6e17;
--amber-soft: #fff7e9;
--shadow: 0 8px 24px rgba(24, 28, 35, 0.07);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
--font-ui: "Noto Sans SC", sans-serif;
font-family: var(--font-ui);
font-size: 15px;
font-synthesis: none;
}
@@ -227,8 +228,10 @@ svg {
}
.nav-item:disabled .nav-badge {
background: #555960;
color: #a9adb4;
background: #4a4245;
color: #8b8185;
border-color: #62575b;
box-shadow: none;
}
.nav-item.active:disabled {
@@ -238,15 +241,22 @@ svg {
}
.nav-badge {
min-width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
justify-self: end;
min-width: 22px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--red);
color: #ffffff;
font-size: 11px;
border: 1px solid #a9c9df;
border-radius: 4px;
background: #dceaf4;
color: #285f96;
font-size: 12px;
font-weight: 650;
line-height: 20px;
font-variant-numeric: tabular-nums;
box-shadow: none;
text-align: center;
}
@@ -563,7 +573,7 @@ svg {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: #fbfbfc;
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 13px;
line-height: 22px;
}
@@ -735,7 +745,7 @@ input:checked + .checkmark {
.task-primary code {
overflow: hidden;
color: #3e4249;
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 13px;
line-height: 20px;
text-overflow: ellipsis;
@@ -831,7 +841,7 @@ input:checked + .checkmark {
.metric-strip strong {
margin-top: 3px;
font-size: 22px;
font-weight: 680;
font-weight: 500;
line-height: 27px;
}
@@ -857,6 +867,7 @@ input:checked + .checkmark {
gap: 3px;
margin-top: 0;
font-size: 18px;
font-weight: 500;
line-height: 20px;
}
@@ -1013,7 +1024,7 @@ input:checked + .checkmark {
.queue-detail code {
overflow: hidden;
color: #3e4249;
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1129,7 +1140,7 @@ input:checked + .checkmark {
.file-download-head strong {
min-width: 0;
color: var(--text);
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
line-height: 19px;
@@ -1137,13 +1148,17 @@ input:checked + .checkmark {
white-space: normal;
}
.file-download-head span {
.file-download-meta {
display: flex;
flex: none;
flex-direction: column;
align-items: flex-end;
color: var(--blue);
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 13px;
font-weight: 650;
line-height: 19px;
text-align: right;
white-space: nowrap;
}
@@ -1449,7 +1464,7 @@ input:checked + .checkmark {
.history-record code {
overflow: hidden;
color: #3e4249;
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1563,7 +1578,7 @@ input:checked + .checkmark {
border-radius: 8px;
background: #1b1d21;
color: #d9dce1;
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 11px;
line-height: 21px;
}
@@ -1575,7 +1590,7 @@ input:checked + .checkmark {
.log-console > div {
display: grid;
grid-template-columns: 70px 44px minmax(0, 1fr);
grid-template-columns: 128px 44px minmax(0, 1fr);
gap: 10px;
min-height: 25px;
align-items: start;
@@ -1583,6 +1598,7 @@ input:checked + .checkmark {
.log-console time {
color: #727780;
white-space: nowrap;
}
.log-console p {
@@ -1690,7 +1706,7 @@ input:checked + .checkmark {
.group-heading p {
color: var(--muted);
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 11px;
line-height: 18px;
text-align: right;
@@ -1730,7 +1746,7 @@ input:checked + .checkmark {
min-height: 92px;
padding: 10px 11px;
resize: vertical;
font-family: "Cascadia Mono", Consolas, monospace;
font-family: var(--font-ui);
font-size: 12px;
line-height: 19px;
}
@@ -2325,7 +2341,9 @@ dialog.disclaimer-modal .modal-footer button:focus-visible {
}
.nav-badge {
box-shadow: 0 0 0 2px #5c3541;
border-color: #b9d5e7;
background: #e6f1f8;
color: #285f96;
}
.topbar {
@@ -2520,7 +2538,7 @@ dialog.disclaimer-modal .modal-footer button:focus-visible {
color: #49373d;
}
.file-download-head span {
.file-download-meta {
color: #2e6fba;
}
@@ -3075,7 +3093,7 @@ dialog.modal {
}
.log-console > div {
grid-template-columns: 62px 40px minmax(0, 1fr);
grid-template-columns: 124px 40px minmax(0, 1fr);
gap: 5px;
}