diff --git a/server/routes/catalog.py b/server/routes/catalog.py index fca638c..8543a22 100644 --- a/server/routes/catalog.py +++ b/server/routes/catalog.py @@ -18,7 +18,7 @@ async def load_catalog(body: SessionBody): async def generate(): try: - async for event in catalog.stream(token, raw_device): + async for event in catalog.stream(token, raw_device, body.refresh): yield json.dumps(event, ensure_ascii=False) + "\n" except Exception as exc: message = str(exc) or "获取清单失败" diff --git a/server/schemas.py b/server/schemas.py index 72cca55..de5735a 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -34,6 +34,7 @@ class AdbBody(BaseModel): class SessionBody(BaseModel): token: str device: DeviceBody | None = None + refresh: bool = False class ProxyBody(BaseModel): diff --git a/server/services/catalog.py b/server/services/catalog.py index 12e78f2..8dffb72 100644 --- a/server/services/catalog.py +++ b/server/services/catalog.py @@ -4,6 +4,7 @@ from server.services.covers import covers from server.services.device import DeviceProfile from server.services.http import http from server.services.rapt import API_BASE +from server.services import catalog_cache from server.services.sessions import sessions CLASSIFY_MAX_PAGES = 50 @@ -39,7 +40,27 @@ class CatalogService: raise RuntimeError("没有获取到分类") return rows - async def stream(self, token: str, raw_device: dict | None): + async def stream(self, token: str, raw_device: dict | None, refresh: bool = False): + if catalog_cache.enabled(): + if refresh: + catalog_cache.clear() + else: + cached = catalog_cache.read() + if cached: + for event in cached: + yield event + return + collected: list[dict] = [] + failed = False + async for event in self._fetch(token, raw_device): + if event.get("failed"): + failed = True + collected.append(event) + yield event + if catalog_cache.enabled() and not failed and collected and collected[-1].get("type") == "done": + catalog_cache.write(collected) + + async def _fetch(self, token: str, raw_device: dict | None): token = token.strip() if not token: raise RuntimeError("请先登录") @@ -79,7 +100,10 @@ class CatalogService: for event in self._iter_paged(key, name, token, device, path, method, body, max_pages, note): loop.call_soon_threadsafe(queue.put_nowait, event) except Exception as exc: - loop.call_soon_threadsafe(queue.put_nowait, self._event(key, name, [], str(exc) or "获取清单失败", True)) + loop.call_soon_threadsafe( + queue.put_nowait, + self._event(key, name, [], str(exc) or "获取清单失败", done=True, failed=True), + ) finally: loop.call_soon_threadsafe(queue.put_nowait, None) @@ -101,8 +125,11 @@ class CatalogService: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False) -> dict: - return {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done} + def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False, failed: bool = False) -> dict: + event = {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done} + if failed: + event["failed"] = True + return event def _drama(self, item: dict) -> dict | None: vid = item.get("id") diff --git a/server/services/catalog_cache.py b/server/services/catalog_cache.py new file mode 100644 index 0000000..d36c1f5 --- /dev/null +++ b/server/services/catalog_cache.py @@ -0,0 +1,41 @@ +"""开发期剧目清单缓存。 + +只缓存分类和剧目清单,不缓存封面。封面仍由 cache/covers 负责。 +手动刷新会删掉这份缓存并重新请求。 +正式打包时 Tauri 会把 RAPTD_CATALOG_CACHE 设为 0,从而关闭这里。 +""" + +import json +import os +from pathlib import Path + +PATH = Path(__file__).resolve().parents[2] / "cache" / "catalog.json" + + +def enabled() -> bool: + flag = os.environ.get("RAPTD_CATALOG_CACHE", "1").strip().lower() + return flag not in ("0", "false", "no") + + +def read() -> list[dict] | None: + if not PATH.is_file(): + return None + try: + data = json.loads(PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + events = data.get("events") if isinstance(data, dict) else None + if not isinstance(events, list) or not events: + return None + return events + + +def write(events: list[dict]) -> None: + PATH.parent.mkdir(parents=True, exist_ok=True) + temporary = PATH.with_suffix(".json.tmp") + temporary.write_text(json.dumps({"events": events}, ensure_ascii=False), encoding="utf-8") + temporary.replace(PATH) + + +def clear() -> None: + PATH.unlink(missing_ok=True) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48c3109..885bb48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,7 +52,11 @@ fn spawn_backend(root: &Path, port: u16) -> Result { .arg("--port") .arg(port.to_string()) .env("RAPTD_STATIC", &static_dir) - .env("PYTHONUNBUFFERED", "1"); + .env("PYTHONUNBUFFERED", "1") + .env( + "RAPTD_CATALOG_CACHE", + if cfg!(debug_assertions) { "1" } else { "0" }, + ); #[cfg(all(windows, not(debug_assertions)))] { use std::os::windows::process::CommandExt; diff --git a/ui/src/App.vue b/ui/src/App.vue index d1c31d7..7ae6fb9 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -81,7 +81,17 @@ const error = ref(""); const token = ref(""); const profile = ref(null); const page = ref<"download" | "profile">("download"); -const downloadRef = ref<{ reload: () => void; busy: boolean } | null>(null); +const downloadRef = ref<{ + reload: (refresh?: boolean) => void; + busy: boolean; + find: (keyword?: string) => void; + searchHint: string; + clearSearch: () => void; +} | null>(null); +const catalogQuery = ref(""); +watch(catalogQuery, (value) => { + if (!String(value ?? "").trim()) downloadRef.value?.clearSearch(); +}); let profileTimer = 0; let profilePulling = false; const builtinDevice = ref({ @@ -531,8 +541,19 @@ onUnmounted(stopProfileWatch); {{ page === "download" ? "视频清单" : "个人信息" }} +
- 刷新 + 刷新
@@ -571,9 +592,6 @@ onUnmounted(stopProfileWatch);
- - RaptDrama -
@@ -811,11 +829,22 @@ html, body, #app { height: 100%; margin: 0; } stroke-linejoin: round; } .bar { - position: sticky; top: 0; z-index: 2; + position: relative; display: flex; align-items: center; justify-content: space-between; height: auto; padding: 12px 20px; } -.bar-actions { display: flex; gap: 8px; } +.bar-search { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + gap: 8px; +} +.search-hint { min-width: 2.5em; } +.bar-actions { display: flex; align-items: center; gap: 8px; } +.catalog-query { width: 220px; } .card { max-width: 560px; margin: 0 auto; } .card .n-tabs { margin-bottom: 16px; } .card .n-form-item { margin-bottom: 16px; } diff --git a/ui/src/views/VideoListPage.vue b/ui/src/views/VideoListPage.vue index 5c8937c..7566047 100644 --- a/ui/src/views/VideoListPage.vue +++ b/ui/src/views/VideoListPage.vue @@ -23,8 +23,16 @@ const expanded = ref([]); const selected = ref>(new Set()); const busy = ref(false); const error = ref(""); +const query = ref(""); +const searchHint = ref(""); +const flashKey = ref(""); let catalogAbort: AbortController | null = null; let catalogGeneration = 0; +let searchCursor = 0; +let lastQuery = ""; +let revealGeneration = 0; +const flashSerial = ref(0); +const scrollEl = ref(null); function coverOf(image: string) { if (!image) return ""; @@ -98,7 +106,7 @@ function applyPage(event: { key: string; name: string; note?: string; items?: Dr if (event.done) section.loading = false; } -async function reload() { +async function reload(refresh = false) { catalogAbort?.abort(); const current = new AbortController(); catalogAbort = current; @@ -109,7 +117,7 @@ async function reload() { const response = await fetch("/api/catalog", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: props.token, device: props.device }), + body: JSON.stringify({ token: props.token, device: props.device, refresh }), signal: current.signal, }); if (!response.ok || !response.body) { @@ -157,14 +165,124 @@ async function reload() { } } +function cardKey(sectionKey: string, id: number) { + return `${sectionKey}-${id}`; +} + +function matches() { + const keyword = query.value.trim().toLowerCase(); + const found: { sectionKey: string; id: number }[] = []; + if (!keyword) return found; + for (const section of sections.value) { + for (const item of section.items) { + if (item.title.toLowerCase().includes(keyword)) { + found.push({ sectionKey: section.key, id: item.id }); + } + } + } + return found; +} + +function clearSearch() { + searchHint.value = ""; + flashKey.value = ""; + lastQuery = ""; + searchCursor = 0; +} + +function finishFlash(key: string, serial: number, event: AnimationEvent) { + if (event.animationName !== "drama-flash") return; + if (flashKey.value === key && flashSerial.value === serial) flashKey.value = ""; +} + +function cardInView(node: HTMLElement, scroller: HTMLElement) { + const card = node.getBoundingClientRect(); + const view = scroller.getBoundingClientRect(); + return card.top >= view.top && card.bottom <= view.bottom; +} + +function scrollCardIntoView(node: HTMLElement) { + const scroller = scrollEl.value; + return new Promise((resolve) => { + if (!scroller) { + resolve(); + return; + } + node.scrollIntoView({ block: "center", behavior: "smooth" }); + let last = scroller.scrollTop; + let stable = 0; + let moved = false; + const started = performance.now(); + const tick = () => { + const current = scroller.scrollTop; + if (Math.abs(current - last) >= 0.5) { + moved = true; + stable = 0; + } else { + stable += 1; + } + last = current; + const arrived = cardInView(node, scroller); + const stopped = moved ? stable >= 4 : stable >= 8; + if ((stopped && arrived) || performance.now() - started > 3000) { + resolve(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +async function find(keyword = query.value) { + const generation = ++revealGeneration; + const text = keyword.trim(); + query.value = text; + if (!text) { + searchHint.value = "请输入名称"; + return; + } + const found = matches(); + if (!found.length) { + searchHint.value = "没有找到"; + flashKey.value = ""; + return; + } + if (text.toLowerCase() !== lastQuery) { + lastQuery = text.toLowerCase(); + searchCursor = 0; + } else { + searchCursor = (searchCursor + 1) % found.length; + } + const hit = found[searchCursor]; + const key = cardKey(hit.sectionKey, hit.id); + searchHint.value = found.length > 1 ? `${searchCursor + 1}/${found.length}` : ""; + flashKey.value = ""; + if (!isExpanded(hit.sectionKey)) { + expanded.value = [...expanded.value, hit.sectionKey]; + } + await nextTick(); + if (generation !== revealGeneration) return; + const node = scrollEl.value?.querySelector(`[data-card="${key}"]`); + if (node instanceof HTMLElement) await scrollCardIntoView(node); + if (generation !== revealGeneration) return; + if (node instanceof HTMLElement && scrollEl.value && !cardInView(node, scrollEl.value)) { + node.scrollIntoView({ block: "center", behavior: "auto" }); + } + flashSerial.value += 1; + flashKey.value = ""; + await nextTick(); + if (generation !== revealGeneration) return; + flashKey.value = key; +} onMounted(reload); -defineExpose({ reload, busy }); +defineExpose({ reload, busy, find, searchHint, clearSearch });