283 lines
8.9 KiB
Vue
283 lines
8.9 KiB
Vue
<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>
|