feat: 增加桌面端登录、视频清单和封面缓存

This commit is contained in:
Nixevol
2026-09-27 03:57:00 +08:00
parent 0c1e04464a
commit cc930cd1ed
69 changed files with 9895 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
<script setup lang="ts">
interface Drama {
id: number;
title: string;
image: string;
}
interface Section {
key: string;
name: string;
note: string;
items: Drama[];
loading: boolean;
}
const props = defineProps<{
token: string;
device: object | null;
}>();
const sections = ref<Section[]>([]);
const expanded = ref<string[]>([]);
const selected = ref<Set<number>>(new Set());
const busy = ref(false);
const error = ref("");
let catalogAbort: AbortController | null = null;
let catalogGeneration = 0;
function coverOf(image: string) {
if (!image) return "";
if (image.startsWith("/api/covers/")) return image;
if (image.startsWith("http")) return image;
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
}
function isExpanded(key: string) {
return expanded.value.includes(key);
}
function toggleExpanded(key: string) {
expanded.value = isExpanded(key)
? expanded.value.filter((item) => item !== key)
: [...expanded.value, key];
}
function selectedCount(section: Section) {
return section.items.filter((item) => selected.value.has(item.id)).length;
}
function allChecked(section: Section) {
return section.items.length > 0 && selectedCount(section) === section.items.length;
}
function someChecked(section: Section) {
const count = selectedCount(section);
return count > 0 && count < section.items.length;
}
function toggleDrama(id: number, checked: boolean) {
const next = new Set(selected.value);
if (checked) next.add(id);
else next.delete(id);
selected.value = next;
}
function toggleSection(section: Section, checked: boolean) {
const next = new Set(selected.value);
for (const item of section.items) {
if (checked) next.add(item.id);
else next.delete(item.id);
}
selected.value = next;
}
function pruneSelection() {
const ids = new Set(sections.value.flatMap((section) => section.items.map((item) => item.id)));
selected.value = new Set([...selected.value].filter((id) => ids.has(id)));
}
function showOutline(list: { key: string; name: string; note?: string; loading?: boolean }[]) {
sections.value = list.map((item) => ({
key: item.key,
name: item.name,
note: item.note || "",
items: [],
loading: item.loading !== false,
}));
}
function applyPage(event: { key: string; name: string; note?: string; items?: Drama[]; done?: boolean }) {
let section = sections.value.find((item) => item.key === event.key);
if (!section) {
section = { key: event.key, name: event.name, note: event.note || "", items: [], loading: !event.done };
sections.value.push(section);
}
if (event.note) section.note = event.note;
if (event.items?.length) section.items = section.items.concat(event.items);
if (event.done) section.loading = false;
}
async function reload() {
catalogAbort?.abort();
const current = new AbortController();
catalogAbort = current;
const generation = ++catalogGeneration;
error.value = "";
busy.value = true;
try {
const response = await fetch("/api/catalog", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: props.token, device: props.device }),
signal: current.signal,
});
if (!response.ok || !response.body) {
let message = "获取清单失败";
try {
const payload = await response.json();
if (typeof payload.detail === "string") message = payload.detail;
} catch {
// 失败响应没有 JSON 时沿用默认提示
}
throw new Error(message);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim() || generation !== catalogGeneration) continue;
const event = JSON.parse(line);
if (event.type === "error") throw new Error(event.message || "获取清单失败");
if (event.type === "outline") {
showOutline(event.sections || []);
continue;
}
if (event.type === "done") {
pruneSelection();
continue;
}
if (event.type !== "page") continue;
applyPage(event);
}
}
} catch (exc) {
if (exc instanceof DOMException && exc.name === "AbortError") return;
if (generation !== catalogGeneration) return;
error.value = exc instanceof Error ? exc.message : "获取清单失败";
for (const section of sections.value) section.loading = false;
} finally {
if (generation === catalogGeneration) busy.value = false;
}
}
onMounted(reload);
defineExpose({ reload, busy });
</script>
<template>
<div class="download-page">
<div class="download-body">
<div class="download-scroll">
<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">
<div class="category-bar">
<n-button quaternary circle :aria-label="isExpanded(section.key) ? '收起' : '展开'" @click="toggleExpanded(section.key)">
<span class="chevron" :class="{ open: isExpanded(section.key) }">▸</span>
</n-button>
<n-text strong>{{ section.name }}</n-text>
<span v-if="section.loading" class="category-spin" aria-hidden="true"></span>
<n-text depth="3">{{ section.items.length }} 部</n-text>
<n-text depth="3">已选 {{ selectedCount(section) }}</n-text>
<span class="bar-gap"></span>
<n-checkbox
:checked="allChecked(section)"
:indeterminate="someChecked(section)"
:disabled="section.items.length === 0"
@update:checked="toggleSection(section, $event)"
>
全选
</n-checkbox>
</div>
<div v-if="isExpanded(section.key)" class="category-body">
<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>
<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))">
<div class="cover">
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
</div>
<div class="pick" @click.stop>
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
</div>
<div class="drama-title">{{ item.title }}</div>
</article>
</div>
</div>
</section>
</div>
</div>
<footer class="download-foot">
<n-text>已选 {{ selected.size }} 部</n-text>
</footer>
</div>
</template>
<style>
.download-page { height: 100%; min-height: 0; display: flex; flex-direction: column; }
.download-body { position: relative; flex: 1; min-height: 0; }
.download-scroll { height: 100%; overflow: auto; padding: 0 0 12px; }
.download-foot {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
background: #18181c;
}
.catalog-error { margin: 8px 20px 12px; }
.category { margin-bottom: 8px; }
.category-bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 20px;
background: #101014;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.category-spin {
width: 14px;
height: 14px;
flex: none;
border-radius: 50%;
border: 2px solid rgba(99, 226, 183, 0.28);
border-top-color: #63e2b7;
animation: category-spin 0.7s linear infinite;
}
@keyframes category-spin { to { transform: rotate(360deg); } }
.bar-gap { flex: 1; }
.chevron { display: inline-block; transition: transform 0.15s ease; }
.chevron.open { transform: rotate(90deg); }
.category-body { padding: 8px 20px 12px 60px; }
.drama-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 12px;
}
.drama-card {
position: relative;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.cover { background: #2a2a2a; }
.cover img { display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; }
.cover:not(:has(img)) { aspect-ratio: 3 / 4; }
.pick {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(0, 0, 0, 0.45);
}
.drama-title { padding: 8px; font-size: 13px; line-height: 1.35; }
</style>