mirror of
https://github.com/JoeanAmier/XHS-Downloader.git
synced 2026-10-07 16:27:53 +08:00
perf(app): 优化图形用户交互界面
This commit is contained in:
+38
-34
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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": _("拖动或点击启用"),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user