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
+832
View File
@@ -0,0 +1,832 @@
<script setup lang="ts">
import { darkTheme, dateZhCN, zhCN, type GlobalThemeOverrides } from "naive-ui";
import VideoListPage from "./views/VideoListPage.vue";
type Method = "guest" | "password" | "adb";
interface Profile {
nickname?: string;
email?: string | null;
id?: string | number;
badge?: string;
vipday?: number;
score?: number;
watched?: number;
rewards?: number;
login_day?: number;
email_bind?: number;
mobile_bind?: number;
google_bind?: number;
apple_bind?: number;
avatarNumUrl?: string;
}
interface Device {
serial: string;
model: string;
rooted: boolean;
state: string;
}
interface SavedAccount {
email: string;
password: string;
nickname: string;
uid: string;
}
interface DeviceProfile {
brand: string;
model: string;
os_version: string;
device_type: string;
physical: boolean;
device_id: string;
fingerprint: string;
app_version: string;
app_build: string;
language: string;
}
interface ProxySettings {
mode: "off" | "system" | "custom";
scheme: "http" | "socks5";
host: string;
port: string;
username: string;
password: string;
}
function emptyProxy(): ProxySettings {
return { mode: "off", scheme: "http", host: "", port: "", username: "", password: "" };
}
const DEVICE_KEY = "rapt-device-settings";
const SESSION_DEVICE_KEY = "rapt-device";
const ACCOUNTS_KEY = "rapt-accounts";
const themeOverrides: GlobalThemeOverrides = {
Form: {
labelFontSizeLeftSmall: "14px",
},
};
const method = ref<Method>("guest");
const email = ref("");
const password = ref("");
const serial = ref<string | null>(null);
const devices = ref<Device[]>([]);
const devicesBusy = ref(false);
const busy = ref(false);
const error = ref("");
const token = ref("");
const profile = ref<Profile | null>(null);
const page = ref<"download" | "profile">("download");
const downloadRef = ref<{ reload: () => void; busy: boolean } | null>(null);
let profileTimer = 0;
let profilePulling = false;
const builtinDevice = ref<DeviceProfile>({
brand: "Redmi",
model: "23113RKC6C",
os_version: "12",
device_type: "phone",
physical: true,
device_id: "rd_c9f0fa17a39f96aa",
fingerprint: "0a4e4b212cbb0a2434cf804c8e30ab58",
app_version: "1.1.81",
app_build: "139",
language: "en-US",
});
const settings = ref({
adbMode: "device" as "device" | "settings",
custom: { ...builtinDevice.value },
proxy: emptyProxy(),
});
const settingsOpen = ref(false);
const settingsError = ref("");
const settingsTab = ref<"device" | "proxy">("device");
const draft = ref(copySettings(settings.value));
const rememberPassword = ref(true);
const savedAccounts = ref<SavedAccount[]>([]);
const selectedAccount = ref<string | null>(null);
const methods: { id: Method; label: string }[] = [
{ id: "guest", label: "游客" },
{ id: "password", label: "账号密码" },
{ id: "adb", label: "ADB" },
];
const fields: { key: keyof Profile; label: string; format?: (value: unknown) => string }[] = [
{ key: "nickname", label: "昵称" },
{ key: "email", label: "邮箱" },
{ key: "id", label: "用户 ID" },
{ key: "badge", label: "徽章" },
{ key: "vipday", label: "VIP 天数" },
{ key: "score", label: "积分" },
{ key: "watched", label: "观看" },
{ key: "rewards", label: "奖励" },
{ key: "login_day", label: "登录天数" },
{ key: "email_bind", label: "邮箱绑定", format: yesNo },
{ key: "mobile_bind", label: "手机绑定", format: yesNo },
{ key: "google_bind", label: "Google 绑定", format: yesNo },
{ key: "apple_bind", label: "Apple 绑定", format: yesNo },
];
const deviceFields: { key: keyof DeviceProfile; label: string; kind?: "select" | "switch" | "language" }[] = [
{ key: "brand", label: "品牌" },
{ key: "model", label: "型号" },
{ key: "os_version", label: "系统版本" },
{ key: "device_type", label: "设备类型", kind: "select" },
{ key: "physical", label: "真机", kind: "switch" },
{ key: "device_id", label: "设备 ID" },
{ key: "fingerprint", label: "指纹" },
{ key: "app_version", label: "App 版本" },
{ key: "app_build", label: "App 构建" },
{ key: "language", label: "语言", kind: "language" },
];
const languageOptions = [
{ label: "English", value: "en-US" },
{ label: "繁體中文", value: "zh-TW" },
{ label: "ภาษาไทย", value: "th-TH" },
{ label: "한국어", value: "ko-KR" },
{ label: "日本語", value: "ja-JP" },
{ label: "हिन्दी", value: "hi-IN" },
{ label: "Bahasa Melayu (Malaysia)", value: "ms-MY" },
{ label: "Português (Brasil)", value: "pt-BR" },
];
function copySettings<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
function loadSettings() {
try {
const data = JSON.parse(localStorage.getItem(DEVICE_KEY) || "");
if (data.adbMode === "device" || data.adbMode === "settings") settings.value.adbMode = data.adbMode;
if (data.mode === "default") {
settings.value.custom = { ...builtinDevice.value };
} else if (data.custom && typeof data.custom === "object") {
settings.value.custom = { ...builtinDevice.value, ...data.custom };
}
if (data.proxy && typeof data.proxy === "object") {
const proxy = { ...emptyProxy(), ...data.proxy };
if (proxy.mode !== "off" && proxy.mode !== "system" && proxy.mode !== "custom") proxy.mode = "off";
if (proxy.scheme !== "http" && proxy.scheme !== "socks5") proxy.scheme = "http";
settings.value.proxy = proxy;
}
} catch {
settings.value.custom = { ...builtinDevice.value };
}
}
async function loadDeviceDefaults() {
const response = await fetch("/api/device/defaults");
if (!response.ok) return;
builtinDevice.value = { ...builtinDevice.value, ...(await response.json()) };
}
function openSettings() {
settingsError.value = "";
draft.value = copySettings(settings.value);
settingsOpen.value = true;
}
// 打开页面时把本机保存的代理推给后端,避免进程重启后丢失。
async function applyProxy(proxy: ProxySettings) {
const response = await fetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(proxy),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof payload.detail === "string" ? payload.detail : "代理设置失败");
}
}
async function saveSettings() {
settingsError.value = "";
const proxy = draft.value.proxy;
if (proxy.mode === "custom") {
if (!proxy.host.trim()) {
settingsError.value = "请填写代理地址";
return;
}
const port = Number(proxy.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
settingsError.value = "请填写 1 到 65535 的端口";
return;
}
}
try {
await applyProxy(proxy);
} catch (exc) {
settingsError.value = exc instanceof Error ? exc.message : "代理设置失败";
return;
}
settings.value = copySettings(draft.value);
localStorage.setItem(DEVICE_KEY, JSON.stringify(settings.value));
settingsOpen.value = false;
}
function setDraftField(key: keyof DeviceProfile, value: string | boolean) {
draft.value.custom = { ...draft.value.custom, [key]: value };
}
function restoreDevice() {
draft.value.custom = { ...builtinDevice.value };
draft.value.adbMode = "device";
}
function settingsDevice(): DeviceProfile {
return settings.value.custom;
}
function rememberSessionDevice(device: DeviceProfile | undefined) {
if (!device) return;
sessionStorage.setItem(SESSION_DEVICE_KEY, JSON.stringify(device));
}
function sessionDevice(): DeviceProfile | null {
try {
const raw = sessionStorage.getItem(SESSION_DEVICE_KEY);
return raw ? JSON.parse(raw) : settingsDevice();
} catch {
return settingsDevice();
}
}
const deviceOptions = computed(() =>
devices.value.map((item) => ({
label: deviceLabel(item),
value: item.serial,
disabled: item.state !== "device",
})),
);
const accountOptions = computed(() =>
savedAccounts.value.map((item) => ({
label: item.nickname ? `${item.nickname} · ${item.email}` : item.email,
value: item.email,
})),
);
function readList<T>(key: string): T[] {
try {
const data = JSON.parse(localStorage.getItem(key) || "[]");
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function writeAccounts() {
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(savedAccounts.value));
}
function applyAccount(emailValue: string) {
const found = savedAccounts.value.find((item) => item.email === emailValue);
if (!found) return;
selectedAccount.value = found.email;
email.value = found.email;
password.value = found.password;
}
function removeAccount() {
if (!selectedAccount.value) return;
const removing = selectedAccount.value;
savedAccounts.value = savedAccounts.value.filter((item) => item.email !== removing);
writeAccounts();
if (email.value === removing) {
email.value = "";
password.value = "";
}
selectedAccount.value = savedAccounts.value[0]?.email ?? null;
if (selectedAccount.value) applyAccount(selectedAccount.value);
}
function saveAccount(info: Profile) {
const next: SavedAccount = {
email: email.value.trim(),
password: password.value,
nickname: info.nickname || "",
uid: info.id == null ? "" : String(info.id),
};
if (!next.email) return;
savedAccounts.value = [next, ...savedAccounts.value.filter((item) => item.email !== next.email)];
writeAccounts();
selectedAccount.value = next.email;
}
function yesNo(value: unknown) {
return Number(value) ? "已绑定" : "未绑定";
}
function textOf(field: (typeof fields)[number]) {
const value = profile.value?.[field.key];
if (value === undefined || value === null || value === "") return "—";
return field.format ? field.format(value) : String(value);
}
function deviceLabel(item: Device) {
const mark =
item.state === "unauthorized" ? "未授权" : item.state === "offline" ? "离线" : item.rooted ? "已 root" : "未 root";
return `${item.model} · ${item.serial} · ${mark}`;
}
async function loadDevices() {
error.value = "";
devicesBusy.value = true;
try {
const response = await fetch("/api/adb/devices");
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.detail || "读取设备失败");
}
devices.value = payload.devices || [];
const stillThere = devices.value.some((item) => item.serial === serial.value && item.state === "device");
if (!stillThere) {
const ready = devices.value.filter((item) => item.state === "device");
serial.value = ready.length === 1 ? ready[0].serial : null;
}
} catch (exc) {
devices.value = [];
serial.value = null;
error.value = exc instanceof Error ? exc.message : "读取设备失败";
} finally {
devicesBusy.value = false;
}
}
watch(method, (value) => {
error.value = "";
if (value === "adb") loadDevices();
});
async function login() {
error.value = "";
busy.value = true;
try {
let path = "/api/login/guest";
let body: unknown = { device: settingsDevice() };
if (method.value === "password") {
path = "/api/login/password";
body = { email: email.value, password: password.value, device: settingsDevice() };
} else if (method.value === "adb") {
if (!serial.value) {
throw new Error("请选择设备");
}
path = "/api/login/adb";
const current = settings.value.custom;
body = {
serial: serial.value,
device_source: settings.value.adbMode,
device:
settings.value.adbMode === "device"
? { fingerprint: current.fingerprint, language: current.language }
: current,
};
}
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(typeof payload.detail === "string" ? payload.detail : "登录失败");
}
token.value = payload.token;
profile.value = payload.profile;
sessionStorage.setItem("rapt-token", payload.token);
rememberSessionDevice(payload.device);
page.value = "download";
startProfileWatch();
if (method.value === "password" && rememberPassword.value) saveAccount(payload.profile || {});
} catch (exc) {
error.value = exc instanceof Error ? exc.message : "登录失败";
} finally {
busy.value = false;
}
}
const avatarSrc = computed(() => {
const image = profile.value?.avatarNumUrl || "";
if (!image) return undefined;
if (image.startsWith("http://") || image.startsWith("https://")) return image;
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
});
function startProfileWatch() {
stopProfileWatch();
profileTimer = window.setInterval(() => {
void pullProfile();
}, 3000);
}
function stopProfileWatch() {
if (profileTimer) window.clearInterval(profileTimer);
profileTimer = 0;
}
async function pullProfile() {
if (!token.value || profilePulling) return;
profilePulling = true;
try {
const response = await fetch("/api/profile");
if (!response.ok) return;
const payload = await response.json();
if (payload.profile) profile.value = payload.profile;
} catch {
// 保留上一份资料,下一轮再读缓存
} finally {
profilePulling = false;
}
}
async function logout() {
const current = token.value;
stopProfileWatch();
token.value = "";
profile.value = null;
page.value = "download";
sessionStorage.removeItem("rapt-token");
sessionStorage.removeItem(SESSION_DEVICE_KEY);
if (!current) return;
try {
await fetch("/api/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: current }),
});
} catch {
// 本地登录态已经清掉
}
}
onMounted(async () => {
await loadDeviceDefaults();
loadSettings();
try {
await applyProxy(settings.value.proxy);
} catch {
error.value = "代理还没有生效,请重新打开设置并保存";
}
savedAccounts.value = readList<SavedAccount>(ACCOUNTS_KEY);
if (savedAccounts.value[0]) applyAccount(savedAccounts.value[0].email);
const saved = sessionStorage.getItem("rapt-token");
if (!saved) return;
const response = await fetch("/api/me", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: saved, device: sessionDevice() }),
});
if (!response.ok) {
sessionStorage.removeItem("rapt-token");
return;
}
const payload = await response.json();
token.value = payload.token;
profile.value = payload.profile;
rememberSessionDevice(payload.device);
page.value = "download";
startProfileWatch();
});
onUnmounted(stopProfileWatch);
</script>
<template>
<n-config-provider class="app" :theme="darkTheme" :theme-overrides="themeOverrides" :locale="zhCN" :date-locale="dateZhCN">
<n-layout v-if="profile" class="shell" has-sider position="absolute">
<n-layout-sider bordered :width="208" content-style="height: 100%; padding: 0">
<div class="sider">
<n-menu
:value="page === 'download' ? 'download' : null"
:options="[{ label: '视频清单', key: 'download' }]"
@update:value="page = 'download'"
/>
<div class="user-nav" :class="{ active: page === 'profile' }">
<button type="button" class="user-nav-main" @click="page = 'profile'">
<n-avatar round :size="28" :src="avatarSrc" />
<span class="user-nav-name">{{ profile.nickname || "个人信息" }}</span>
</button>
<n-tooltip placement="right">
<template #trigger>
<n-button quaternary circle size="small" aria-label="退出" @click="logout">
<template #icon>
<svg class="logout-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
</template>
</n-button>
</template>
退出
</n-tooltip>
</div>
</div>
</n-layout-sider>
<n-layout class="main-pane">
<n-layout-header bordered class="bar">
<n-text strong>{{ page === "download" ? "视频清单" : "个人信息" }}</n-text>
<div class="bar-actions">
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload()">刷新</n-button>
</div>
</n-layout-header>
<n-layout-content content-style="height: 100%; padding: 0">
<VideoListPage
v-show="page === 'download'"
ref="downloadRef"
:token="token"
:device="sessionDevice()"
/>
<div v-show="page === 'profile'" class="profile-page">
<div class="profile-head">
<n-avatar round :size="72" :src="avatarSrc" />
<n-text class="profile-name" strong>{{ profile.nickname || "未命名" }}</n-text>
<n-tooltip placement="bottom">
<template #trigger>
<n-button quaternary circle aria-label="退出" @click="logout">
<template #icon>
<svg class="logout-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
</template>
</n-button>
</template>
退出
</n-tooltip>
</div>
<n-descriptions label-placement="left" :column="1" size="small" label-style="width: 96px">
<n-descriptions-item v-for="field in fields" :key="field.key" :label="field.label">
{{ textOf(field) }}
</n-descriptions-item>
</n-descriptions>
</div>
</n-layout-content>
</n-layout>
</n-layout>
<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">
<div class="card">
<n-tabs v-model:value="method" type="segment" size="small">
<n-tab v-for="item in methods" :key="item.id" :name="item.id">{{ item.label }}</n-tab>
</n-tabs>
<n-form label-placement="left" label-width="96" size="small" :show-feedback="false" @submit.prevent="login">
<template v-if="method === 'password'">
<n-form-item v-if="savedAccounts.length" label="已保存">
<div class="device-line">
<n-select
:value="selectedAccount"
:options="accountOptions"
placeholder="选择已保存的账号"
@update:value="applyAccount"
/>
<n-button :disabled="!selectedAccount" @click="removeAccount">删除</n-button>
</div>
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="email" type="text" placeholder="邮箱" autocomplete="username" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="password" type="password" show-password-on="click" placeholder="密码" autocomplete="current-password" />
</n-form-item>
<n-form-item label="记住">
<n-checkbox v-model:checked="rememberPassword">记住账号密码</n-checkbox>
</n-form-item>
</template>
<n-form-item v-else-if="method === 'adb'" label="设备">
<div class="device-line">
<n-select
v-model:value="serial"
:options="deviceOptions"
:loading="devicesBusy"
placeholder="请选择设备"
/>
<n-button :loading="devicesBusy" @click="loadDevices">刷新</n-button>
</div>
</n-form-item>
<n-text v-if="method === 'guest'" class="hint" depth="3">将注册一个新的游客账号,并读取它的个人信息。</n-text>
<n-text v-if="method === 'adb' && !devicesBusy && devices.length === 0" class="hint" depth="3">没有检测到已连接的设备。</n-text>
<n-text v-if="method === 'adb'" class="hint" depth="3">从已 root 的设备读取当前登录账号。未 root 的设备无法获取登录信息。默认同时读取这台设备的品牌、型号和系统版本,可在设置里修改。</n-text>
<n-alert v-if="error" class="hint" type="error" :show-icon="false">{{ error }}</n-alert>
<div class="actions">
<n-button type="primary" attr-type="submit" :loading="busy">登录</n-button>
</div>
</n-form>
</div>
</n-layout-content>
</n-layout>
<n-tooltip v-if="!profile" placement="left">
<template #trigger>
<n-button class="settings-button" secondary circle aria-label="设置" @click="openSettings">
<template #icon>
<svg class="gear-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.63-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54c-.58.22-1.12.54-1.63.94l-2.39-.96a.5.5 0 0 0-.6.22L2.71 8.84a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32c.13.22.39.31.6.22l2.39-.96c.51.4 1.05.72 1.63.94l.36 2.54c.05.24.26.42.5.42h3.84c.24 0 .45-.18.5-.42l.36-2.54c.58-.22 1.12-.54 1.63-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5z"
/>
</svg>
</template>
</n-button>
</template>
设置
</n-tooltip>
<n-modal v-model:show="settingsOpen" preset="card" title="设置" style="width: 560px">
<n-tabs v-model:value="settingsTab" type="line" size="small">
<n-tab-pane name="device" tab="设备信息" display-directive="if">
<div class="settings-body">
<n-form label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item v-for="field in deviceFields" :key="field.key" :label="field.label">
<n-switch
v-if="field.kind === 'switch'"
:value="draft.custom.physical"
@update:value="setDraftField('physical', $event)"
/>
<n-select
v-else-if="field.kind === 'select'"
:value="draft.custom.device_type"
:options="[
{ label: '手机', value: 'phone' },
{ label: '平板', value: 'pad' },
]"
@update:value="setDraftField('device_type', $event)"
/>
<n-select
v-else-if="field.kind === 'language'"
:value="draft.custom.language"
:options="languageOptions"
@update:value="setDraftField('language', $event)"
/>
<n-input
v-else
:value="String(draft.custom[field.key] ?? '')"
@update:value="setDraftField(field.key, $event)"
/>
</n-form-item>
<n-form-item label="ADB">
<n-radio-group v-model:value="draft.adbMode">
<n-radio value="device">从设备读取</n-radio>
<n-radio value="settings">使用上面的设置</n-radio>
</n-radio-group>
</n-form-item>
<n-text class="hint" depth="3">从设备读取时会带上品牌、型号、系统版本、设备 ID 和已安装的 App 版本。指纹和语言仍用当前设置里的值。</n-text>
</n-form>
</div>
</n-tab-pane>
<n-tab-pane name="proxy" tab="代理" display-directive="if">
<div class="settings-body">
<n-form label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item label="方式">
<n-radio-group v-model:value="draft.proxy.mode">
<n-radio value="off">不使用</n-radio>
<n-radio value="system">系统代理</n-radio>
<n-radio value="custom">自定义</n-radio>
</n-radio-group>
</n-form-item>
<template v-if="draft.proxy.mode === 'custom'">
<n-form-item label="协议">
<n-radio-group v-model:value="draft.proxy.scheme">
<n-radio value="http">HTTP</n-radio>
<n-radio value="socks5">SOCKS5</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="地址">
<n-input v-model:value="draft.proxy.host" placeholder="代理地址" />
</n-form-item>
<n-form-item label="端口">
<n-input v-model:value="draft.proxy.port" placeholder="1-65535" />
</n-form-item>
<n-form-item label="用户名">
<n-input v-model:value="draft.proxy.username" placeholder="可选" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="draft.proxy.password" type="password" show-password-on="click" placeholder="可选" />
</n-form-item>
</template>
</n-form>
</div>
</n-tab-pane>
</n-tabs>
<n-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
<template #footer>
<div class="actions">
<n-button v-if="settingsTab === 'device'" @click="restoreDevice">还原设置</n-button>
<span class="actions-spacer"></span>
<n-button @click="settingsOpen = false">取消</n-button>
<n-button type="primary" @click="saveSettings">保存</n-button>
</div>
</template>
</n-modal>
</n-config-provider>
</template>
<style>
html, body, #app { height: 100%; margin: 0; }
.app { height: 100%; position: relative; }
.shell .n-layout-scroll-container { overflow-y: auto; }
.shell .main-pane { height: 100%; min-height: 0; }
.shell .main-pane > .n-layout-scroll-container {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.shell .main-pane > .n-layout-scroll-container > .n-layout-content {
flex: 1;
min-height: 0;
}
.shell .main-pane .n-layout-content > .n-layout-scroll-container {
height: 100%;
overflow: hidden;
}
.sider { height: 100%; min-height: 100%; display: flex; flex-direction: column; box-sizing: border-box; padding: 12px 8px; }
.user-nav {
margin-top: auto;
display: flex;
align-items: center;
gap: 2px;
min-height: 48px;
padding: 4px 2px 4px 10px;
border-radius: 6px;
}
.user-nav.active { background: rgba(99, 226, 183, 0.16); }
.user-nav-main {
flex: 1;
min-width: 0;
height: 36px;
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
}
.user-nav-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.user-nav .n-avatar,
.profile-head .n-avatar {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.user-nav .n-avatar img,
.profile-head .n-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.profile-page { height: 100%; overflow: auto; box-sizing: border-box; padding: 28px 20px; }
.profile-head { display: flex; align-items: center; gap: 16px; max-width: 560px; margin: 0 auto 20px; }
.profile-name { flex: 1; min-width: 0; font-size: 18px; line-height: 1.2; }
.profile-page .n-descriptions { max-width: 560px; margin: 0 auto; }
.logout-icon {
width: 18px;
height: 18px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.bar {
position: sticky; top: 0; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
height: auto; padding: 12px 20px;
}
.bar-actions { display: flex; gap: 8px; }
.card { max-width: 560px; margin: 0 auto; }
.card .n-tabs { margin-bottom: 16px; }
.card .n-form-item { margin-bottom: 16px; }
.card .n-avatar { margin-bottom: 16px; }
.device-line { display: flex; gap: 8px; width: 100%; align-items: center; }
.device-line .n-select { flex: 1; min-width: 0; }
.hint { display: block; margin: 8px 0; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.actions-spacer { flex: 1; }
.settings-button { position: fixed; right: 20px; bottom: 20px; z-index: 5; }
.gear-icon { width: 18px; height: 18px; display: block; }
.settings-body { max-height: min(58vh, 520px); overflow: auto; padding-right: 4px; }
.settings-body .n-form-item { margin-bottom: 16px; }
</style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
/* oxlint-disable */
/* oxfmt-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useModel: typeof import('vue').useModel
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+40
View File
@@ -0,0 +1,40 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDescriptions: typeof import('naive-ui')['NDescriptions']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NInput: typeof import('naive-ui')['NInput']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NModal: typeof import('naive-ui')['NModal']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NSelect: typeof import('naive-ui')['NSelect']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip']
}
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
+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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}