feat: 增加视频清单搜索,并在开发期缓存剧目清单
This commit is contained in:
@@ -18,7 +18,7 @@ async def load_catalog(body: SessionBody):
|
|||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
try:
|
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"
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
message = str(exc) or "获取清单失败"
|
message = str(exc) or "获取清单失败"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class AdbBody(BaseModel):
|
|||||||
class SessionBody(BaseModel):
|
class SessionBody(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
device: DeviceBody | None = None
|
device: DeviceBody | None = None
|
||||||
|
refresh: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ProxyBody(BaseModel):
|
class ProxyBody(BaseModel):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from server.services.covers import covers
|
|||||||
from server.services.device import DeviceProfile
|
from server.services.device import DeviceProfile
|
||||||
from server.services.http import http
|
from server.services.http import http
|
||||||
from server.services.rapt import API_BASE
|
from server.services.rapt import API_BASE
|
||||||
|
from server.services import catalog_cache
|
||||||
from server.services.sessions import sessions
|
from server.services.sessions import sessions
|
||||||
|
|
||||||
CLASSIFY_MAX_PAGES = 50
|
CLASSIFY_MAX_PAGES = 50
|
||||||
@@ -39,7 +40,27 @@ class CatalogService:
|
|||||||
raise RuntimeError("没有获取到分类")
|
raise RuntimeError("没有获取到分类")
|
||||||
return rows
|
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()
|
token = token.strip()
|
||||||
if not token:
|
if not token:
|
||||||
raise RuntimeError("请先登录")
|
raise RuntimeError("请先登录")
|
||||||
@@ -79,7 +100,10 @@ class CatalogService:
|
|||||||
for event in self._iter_paged(key, name, token, device, path, method, body, max_pages, note):
|
for event in self._iter_paged(key, name, token, device, path, method, body, max_pages, note):
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, event)
|
loop.call_soon_threadsafe(queue.put_nowait, event)
|
||||||
except Exception as exc:
|
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:
|
finally:
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||||
|
|
||||||
@@ -101,8 +125,11 @@ class CatalogService:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False) -> dict:
|
def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False, failed: bool = False) -> dict:
|
||||||
return {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done}
|
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:
|
def _drama(self, item: dict) -> dict | None:
|
||||||
vid = item.get("id")
|
vid = item.get("id")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -52,7 +52,11 @@ fn spawn_backend(root: &Path, port: u16) -> Result<Child, String> {
|
|||||||
.arg("--port")
|
.arg("--port")
|
||||||
.arg(port.to_string())
|
.arg(port.to_string())
|
||||||
.env("RAPTD_STATIC", &static_dir)
|
.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)))]
|
#[cfg(all(windows, not(debug_assertions)))]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
|||||||
+36
-7
@@ -81,7 +81,17 @@ const error = ref("");
|
|||||||
const token = ref("");
|
const token = ref("");
|
||||||
const profile = ref<Profile | null>(null);
|
const profile = ref<Profile | null>(null);
|
||||||
const page = ref<"download" | "profile">("download");
|
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 profileTimer = 0;
|
||||||
let profilePulling = false;
|
let profilePulling = false;
|
||||||
const builtinDevice = ref<DeviceProfile>({
|
const builtinDevice = ref<DeviceProfile>({
|
||||||
@@ -531,8 +541,19 @@ onUnmounted(stopProfileWatch);
|
|||||||
<n-layout class="main-pane">
|
<n-layout class="main-pane">
|
||||||
<n-layout-header bordered class="bar">
|
<n-layout-header bordered class="bar">
|
||||||
<n-text strong>{{ page === "download" ? "视频清单" : "个人信息" }}</n-text>
|
<n-text strong>{{ page === "download" ? "视频清单" : "个人信息" }}</n-text>
|
||||||
|
<div v-if="page === 'download'" class="bar-search">
|
||||||
|
<n-input
|
||||||
|
v-model:value="catalogQuery"
|
||||||
|
class="catalog-query"
|
||||||
|
placeholder="搜索名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="downloadRef?.find(catalogQuery)"
|
||||||
|
/>
|
||||||
|
<n-button secondary @click="downloadRef?.find(catalogQuery)">搜索</n-button>
|
||||||
|
<n-text v-if="downloadRef?.searchHint" class="search-hint" depth="3">{{ downloadRef.searchHint }}</n-text>
|
||||||
|
</div>
|
||||||
<div class="bar-actions">
|
<div class="bar-actions">
|
||||||
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload()">刷新</n-button>
|
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload(true)">刷新</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-layout-header>
|
</n-layout-header>
|
||||||
<n-layout-content content-style="height: 100%; padding: 0">
|
<n-layout-content content-style="height: 100%; padding: 0">
|
||||||
@@ -571,9 +592,6 @@ onUnmounted(stopProfileWatch);
|
|||||||
</n-layout>
|
</n-layout>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
<n-layout v-else class="shell" position="absolute">
|
<n-layout v-else class="shell" position="absolute">
|
||||||
<n-layout-header bordered class="bar">
|
|
||||||
<n-text strong>RaptDrama</n-text>
|
|
||||||
</n-layout-header>
|
|
||||||
<n-layout-content content-style="padding: 28px 20px">
|
<n-layout-content content-style="padding: 28px 20px">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<n-tabs v-model:value="method" type="segment" size="small">
|
<n-tabs v-model:value="method" type="segment" size="small">
|
||||||
@@ -811,11 +829,22 @@ html, body, #app { height: 100%; margin: 0; }
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
.bar {
|
.bar {
|
||||||
position: sticky; top: 0; z-index: 2;
|
position: relative;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
height: auto; padding: 12px 20px;
|
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 { max-width: 560px; margin: 0 auto; }
|
||||||
.card .n-tabs { margin-bottom: 16px; }
|
.card .n-tabs { margin-bottom: 16px; }
|
||||||
.card .n-form-item { margin-bottom: 16px; }
|
.card .n-form-item { margin-bottom: 16px; }
|
||||||
|
|||||||
@@ -23,8 +23,16 @@ const expanded = ref<string[]>([]);
|
|||||||
const selected = ref<Set<number>>(new Set());
|
const selected = ref<Set<number>>(new Set());
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const query = ref("");
|
||||||
|
const searchHint = ref("");
|
||||||
|
const flashKey = ref("");
|
||||||
let catalogAbort: AbortController | null = null;
|
let catalogAbort: AbortController | null = null;
|
||||||
let catalogGeneration = 0;
|
let catalogGeneration = 0;
|
||||||
|
let searchCursor = 0;
|
||||||
|
let lastQuery = "";
|
||||||
|
let revealGeneration = 0;
|
||||||
|
const flashSerial = ref(0);
|
||||||
|
const scrollEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
function coverOf(image: string) {
|
function coverOf(image: string) {
|
||||||
if (!image) return "";
|
if (!image) return "";
|
||||||
@@ -98,7 +106,7 @@ function applyPage(event: { key: string; name: string; note?: string; items?: Dr
|
|||||||
if (event.done) section.loading = false;
|
if (event.done) section.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reload() {
|
async function reload(refresh = false) {
|
||||||
catalogAbort?.abort();
|
catalogAbort?.abort();
|
||||||
const current = new AbortController();
|
const current = new AbortController();
|
||||||
catalogAbort = current;
|
catalogAbort = current;
|
||||||
@@ -109,7 +117,7 @@ async function reload() {
|
|||||||
const response = await fetch("/api/catalog", {
|
const response = await fetch("/api/catalog", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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,
|
signal: current.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok || !response.body) {
|
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<void>((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);
|
onMounted(reload);
|
||||||
defineExpose({ reload, busy });
|
defineExpose({ reload, busy, find, searchHint, clearSearch });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="download-page">
|
<div class="download-page">
|
||||||
<div class="download-body">
|
<div class="download-body">
|
||||||
<div class="download-scroll">
|
<div ref="scrollEl" class="download-scroll">
|
||||||
<n-alert v-if="error" class="catalog-error" type="error" :show-icon="false">{{ error }}</n-alert>
|
<n-alert v-if="error" class="catalog-error" type="error" :show-icon="false">{{ error }}</n-alert>
|
||||||
<section v-for="section in sections" :key="section.key" class="category">
|
<section v-for="section in sections" :key="section.key" class="category">
|
||||||
<div class="category-bar">
|
<div class="category-bar">
|
||||||
@@ -189,7 +307,13 @@ defineExpose({ reload, busy });
|
|||||||
<n-text v-if="section.note" depth="3">{{ section.note }}</n-text>
|
<n-text v-if="section.note" depth="3">{{ section.note }}</n-text>
|
||||||
<n-text v-else-if="!section.loading && section.items.length === 0" depth="3">暂无剧集</n-text>
|
<n-text v-else-if="!section.loading && section.items.length === 0" depth="3">暂无剧集</n-text>
|
||||||
<div v-else class="drama-grid">
|
<div v-else class="drama-grid">
|
||||||
<article v-for="item in section.items" :key="item.id" class="drama-card" @click="toggleDrama(item.id, !selected.has(item.id))">
|
<article
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="drama-card"
|
||||||
|
:data-card="cardKey(section.key, item.id)"
|
||||||
|
@click="toggleDrama(item.id, !selected.has(item.id))"
|
||||||
|
>
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
|
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
|
||||||
</div>
|
</div>
|
||||||
@@ -197,6 +321,12 @@ defineExpose({ reload, busy });
|
|||||||
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
|
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="drama-title">{{ item.title }}</div>
|
<div class="drama-title">{{ item.title }}</div>
|
||||||
|
<div
|
||||||
|
v-if="flashKey === cardKey(section.key, item.id)"
|
||||||
|
:key="flashSerial"
|
||||||
|
class="drama-mask"
|
||||||
|
@animationend="finishFlash(cardKey(section.key, item.id), flashSerial, $event)"
|
||||||
|
></div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,10 +340,12 @@ defineExpose({ reload, busy });
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.download-page { height: 100%; min-height: 0; display: flex; flex-direction: column; }
|
.download-page { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.download-body { position: relative; flex: 1; min-height: 0; }
|
.download-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.download-scroll { height: 100%; overflow: auto; padding: 0 0 12px; }
|
.download-scroll { flex: 1; min-height: 0; overflow: auto; padding: 0 0 12px; }
|
||||||
.download-foot {
|
.download-foot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -278,5 +410,18 @@ defineExpose({ reload, busy });
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
.drama-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
background: rgba(99, 226, 183, 0.42);
|
||||||
|
animation: drama-flash 0.45s ease-in-out 3;
|
||||||
|
}
|
||||||
|
@keyframes drama-flash {
|
||||||
|
0%, 100% { opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
.drama-title { padding: 8px; font-size: 13px; line-height: 1.35; }
|
.drama-title { padding: 8px; font-size: 13px; line-height: 1.35; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user