feat: 增加桌面端登录、视频清单和封面缓存
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user