feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标

This commit is contained in:
Nixevol
2026-09-27 06:16:50 +08:00
parent f010426f94
commit 1b33898fff
81 changed files with 2529 additions and 232 deletions
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RaptDrama</title>
<title>RaptDrama Download Tools</title>
</head>
<body>
+7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"hls.js": "^1.7.3",
"naive-ui": "^2.45.3",
"vue": "^3.5.13"
},
@@ -824,6 +825,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"hls.js": "^1.7.3",
"naive-ui": "^2.45.3",
"vue": "^3.5.13"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+508 -207
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { darkTheme, dateZhCN, zhCN, type GlobalThemeOverrides } from "naive-ui";
import { getCurrentWindow } from "@tauri-apps/api/window";
import VideoListPage from "./views/VideoListPage.vue";
import DownloadProgressPage from "./views/DownloadProgressPage.vue";
import SettingsPanel from "./views/SettingsPanel.vue";
import PlayerPage from "./views/PlayerPage.vue";
type Method = "guest" | "password" | "adb";
@@ -80,20 +84,76 @@ const busy = ref(false);
const error = ref("");
const token = ref("");
const profile = ref<Profile | null>(null);
const page = ref<"download" | "profile">("download");
const page = ref<"download" | "profile" | "progress" | "settings" | "play">("download");
const playing = ref<{ id: number; title: string; image: string } | null>(null);
const progressCount = ref(0);
interface LiveJob {
id: string;
title: string;
image: string;
status: string;
episode_count: number;
done_count: number;
progress: number;
speed: string;
stage: string;
completed_at: string;
error: string;
}
const downloadState = ref<{ active: LiveJob[]; done: LiveJob[]; active_count: number }>({
active: [],
done: [],
active_count: 0,
});
const downloadRef = ref<{
reload: (refresh?: boolean) => void;
busy: boolean;
find: (keyword?: string) => void;
searchHint: string;
clearSearch: () => void;
loadDirectory: () => Promise<void>;
} | null>(null);
const catalogQuery = ref("");
const mainMenu = computed(() => [
{ label: "视频清单", key: "download" },
{
key: "progress",
label: () =>
h("span", { class: "nav-label" }, [
"下载进度",
progressCount.value
? h("span", { class: "nav-badge" }, String(progressCount.value))
: null,
]),
},
{ label: "系统设置", key: "settings" },
]);
const pageTitle = computed(() => {
if (page.value === "play") return playing.value?.title || "播放";
if (page.value === "progress") return "下载进度";
if (page.value === "profile") return "个人信息";
if (page.value === "settings") return "系统设置";
return "视频清单";
});
const menuValue = computed(() => {
if (page.value === "profile") return null;
if (page.value === "play") return "download";
return page.value;
});
function openPlay(drama: { id: number; title: string; image: string }) {
playing.value = drama;
page.value = "play";
}
function closePlay() {
page.value = "download";
playing.value = null;
}
watch(catalogQuery, (value) => {
if (!String(value ?? "").trim()) downloadRef.value?.clearSearch();
});
let profileTimer = 0;
let profilePulling = false;
let liveSocket: WebSocket | null = null;
const builtinDevice = ref<DeviceProfile>({
brand: "Redmi",
model: "23113RKC6C",
@@ -110,10 +170,14 @@ const settings = ref({
adbMode: "device" as "device" | "settings",
custom: { ...builtinDevice.value },
proxy: emptyProxy(),
workers: 2,
directory: "",
defaultDirectory: "",
});
const settingsOpen = ref(false);
const settingsError = ref("");
const settingsTab = ref<"device" | "proxy">("device");
const settingsBusy = ref(false);
const settingsOpen = ref(false);
const settingsTab = ref<"device" | "proxy" | "download">("device");
const draft = ref(copySettings(settings.value));
const rememberPassword = ref(true);
const savedAccounts = ref<SavedAccount[]>([]);
@@ -125,44 +189,24 @@ const methods: { id: Method; label: string }[] = [
{ id: "adb", label: "ADB" },
];
const fields: { key: keyof Profile; label: string; format?: (value: unknown) => string }[] = [
{ key: "nickname", label: "昵称" },
const accountFields: { key: keyof Profile; label: string }[] = [
{ key: "email", label: "邮箱" },
{ key: "id", label: "用户 ID" },
{ key: "badge", label: "徽章" },
{ key: "vipday", label: "VIP 天数" },
];
const statFields: { key: keyof Profile; label: string }[] = [
{ 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 },
{ key: "login_day", label: "登录" },
];
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" },
const bindFields: { key: keyof Profile; label: string }[] = [
{ key: "email_bind", label: "邮箱" },
{ key: "mobile_bind", label: "手机" },
{ key: "google_bind", label: "Google" },
{ key: "apple_bind", label: "Apple" },
];
function copySettings<T>(value: T): T {
@@ -195,12 +239,37 @@ async function loadDeviceDefaults() {
builtinDevice.value = { ...builtinDevice.value, ...(await response.json()) };
}
function openSettings() {
function normalizeWorkers(value: unknown) {
const count = Number(value);
if (!Number.isInteger(count) || count < 1 || count > 8) return 2;
return count;
}
async function loadDownloadSettings() {
const response = await fetch("/api/downloads/settings");
if (!response.ok) return;
const payload = await response.json();
settings.value.workers = normalizeWorkers(payload.workers);
settings.value.directory = typeof payload.directory === "string" ? payload.directory : "";
settings.value.defaultDirectory = typeof payload.default_directory === "string" ? payload.default_directory : "";
}
async function enterSettings() {
settingsError.value = "";
await loadDownloadSettings();
draft.value = copySettings(settings.value);
}
async function openSettings() {
settingsTab.value = "device";
await enterSettings();
settingsOpen.value = true;
}
watch(page, (value) => {
if (value === "settings") void enterSettings();
});
// 打开页面时把本机保存的代理推给后端,避免进程重启后丢失。
async function applyProxy(proxy: ProxySettings) {
const response = await fetch("/api/proxy", {
@@ -219,33 +288,85 @@ async function saveSettings() {
const proxy = draft.value.proxy;
if (proxy.mode === "custom") {
if (!proxy.host.trim()) {
settingsTab.value = "proxy";
settingsError.value = "请填写代理地址";
return;
}
const port = Number(proxy.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
settingsTab.value = "proxy";
settingsError.value = "请填写 1 到 65535 的端口";
return;
}
}
try {
await applyProxy(proxy);
} catch (exc) {
settingsError.value = exc instanceof Error ? exc.message : "代理设置失败";
const workers = normalizeWorkers(draft.value.workers);
if (workers !== Number(draft.value.workers)) {
settingsTab.value = "download";
settingsError.value = "同时下载数量需要在 1 到 8 之间";
return;
}
settings.value = copySettings(draft.value);
localStorage.setItem(DEVICE_KEY, JSON.stringify(settings.value));
settingsOpen.value = false;
const directory = draft.value.directory.trim();
if (!directory) {
settingsTab.value = "download";
settingsError.value = "请选择下载目录";
return;
}
settingsBusy.value = true;
try {
await applyProxy(proxy);
const response = await fetch("/api/downloads/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ directory, workers }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
settingsTab.value = "download";
settingsError.value = typeof payload.detail === "string" ? payload.detail : "下载设置失败";
return;
}
draft.value.directory = payload.directory || directory;
draft.value.workers = normalizeWorkers(payload.workers);
draft.value.defaultDirectory = payload.default_directory || draft.value.defaultDirectory;
settings.value = copySettings(draft.value);
localStorage.setItem(DEVICE_KEY, JSON.stringify({
adbMode: settings.value.adbMode,
custom: settings.value.custom,
proxy: settings.value.proxy,
}));
await downloadRef.value?.loadDirectory();
settingsOpen.value = false;
} catch (exc) {
settingsError.value = exc instanceof Error ? exc.message : "设置保存失败";
} finally {
settingsBusy.value = false;
}
}
function setDraftField(key: keyof DeviceProfile, value: string | boolean) {
draft.value.custom = { ...draft.value.custom, [key]: value };
async function chooseDownloadDirectory() {
settingsError.value = "";
const response = await fetch("/api/downloads/choose", { method: "POST" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
settingsError.value = typeof payload.detail === "string" ? payload.detail : "目录选择失败";
return;
}
if (typeof payload.directory === "string" && payload.directory) draft.value.directory = payload.directory;
}
function restoreDevice() {
draft.value.custom = { ...builtinDevice.value };
draft.value.adbMode = "device";
function restoreCurrent() {
settingsError.value = "";
if (settingsTab.value === "device") {
draft.value.custom = { ...builtinDevice.value };
draft.value.adbMode = "device";
return;
}
if (settingsTab.value === "proxy") {
draft.value.proxy = emptyProxy();
return;
}
draft.value.workers = 2;
draft.value.directory = draft.value.defaultDirectory || settings.value.defaultDirectory;
}
function settingsDevice(): DeviceProfile {
@@ -328,14 +449,19 @@ function saveAccount(info: Profile) {
selectedAccount.value = next.email;
}
function yesNo(value: unknown) {
return Number(value) ? "已绑定" : "未绑定";
function profileText(key: keyof Profile) {
const value = profile.value?.[key];
if (value === undefined || value === null || value === "") return "—";
return String(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 profileNumber(key: keyof Profile) {
const value = Number(profile.value?.[key] ?? 0);
return Number.isFinite(value) ? String(value) : "0";
}
function profileBound(key: keyof Profile) {
return Number(profile.value?.[key] ?? 0) > 0;
}
function deviceLabel(item: Device) {
@@ -411,7 +537,7 @@ async function login() {
sessionStorage.setItem("rapt-token", payload.token);
rememberSessionDevice(payload.device);
page.value = "download";
startProfileWatch();
startLive();
if (method.value === "password" && rememberPassword.value) saveAccount(payload.profile || {});
} catch (exc) {
error.value = exc instanceof Error ? exc.message : "登录失败";
@@ -427,36 +553,48 @@ const avatarSrc = computed(() => {
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
});
function startProfileWatch() {
stopProfileWatch();
profileTimer = window.setInterval(() => {
void pullProfile();
}, 3000);
}
let liveRetry = 0;
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;
function applyLive(payload: { downloads?: { active: LiveJob[]; done: LiveJob[]; active_count?: number }; profile?: Profile | null }) {
if (payload.downloads) {
downloadState.value = {
active: payload.downloads.active || [],
done: payload.downloads.done || [],
active_count: payload.downloads.active_count || 0,
};
progressCount.value = payload.downloads.active_count || 0;
}
if (payload.profile) profile.value = payload.profile;
}
function startLive() {
stopLive();
const protocol = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${location.host}/api/live`);
liveSocket = socket;
socket.onmessage = (event) => {
applyLive(JSON.parse(event.data));
};
socket.onclose = () => {
if (liveSocket !== socket || !token.value) return;
liveRetry = window.setTimeout(() => startLive(), 1000);
};
}
function stopLive() {
if (liveRetry) window.clearTimeout(liveRetry);
liveRetry = 0;
if (liveSocket) {
liveSocket.onclose = null;
liveSocket.close();
liveSocket = null;
}
progressCount.value = 0;
}
async function logout() {
const current = token.value;
stopProfileWatch();
stopLive();
token.value = "";
profile.value = null;
page.value = "download";
@@ -474,7 +612,51 @@ async function logout() {
}
}
const closeAsk = ref(false);
const closeCount = ref(0);
let allowClose = false;
function inTauri() {
return "__TAURI_INTERNALS__" in window;
}
async function watchClose() {
if (!inTauri()) return;
const win = getCurrentWindow();
await win.onCloseRequested((event) => {
if (allowClose) return;
event.preventDefault();
void askClose(win);
});
}
async function askClose(win: ReturnType<typeof getCurrentWindow>) {
try {
const response = await fetch("/api/downloads");
const payload = await response.json();
const count = Number(payload.active_count || 0);
if (count > 0) {
closeCount.value = count;
closeAsk.value = true;
return;
}
} catch {
// 后台不可用时直接关闭
}
allowClose = true;
await win.destroy();
}
async function confirmQuit() {
closeAsk.value = false;
allowClose = true;
if (!inTauri()) return;
await getCurrentWindow().destroy();
}
onMounted(async () => {
document.title = "RaptDrama Download Tools";
void watchClose();
await loadDeviceDefaults();
loadSettings();
try {
@@ -500,10 +682,10 @@ onMounted(async () => {
profile.value = payload.profile;
rememberSessionDevice(payload.device);
page.value = "download";
startProfileWatch();
startLive();
});
onUnmounted(stopProfileWatch);
onUnmounted(stopLive);
</script>
<template>
@@ -512,9 +694,9 @@ onUnmounted(stopProfileWatch);
<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'"
:value="menuValue"
:options="mainMenu"
@update:value="page = $event"
/>
<div class="user-nav" :class="{ active: page === 'profile' }">
<button type="button" class="user-nav-main" @click="page = 'profile'">
@@ -540,7 +722,10 @@ onUnmounted(stopProfileWatch);
</n-layout-sider>
<n-layout class="main-pane">
<n-layout-header bordered class="bar">
<n-text strong>{{ page === "download" ? "视频清单" : "个人信息" }}</n-text>
<div class="bar-title">
<n-button v-if="page === 'play'" quaternary size="small" @click="closePlay">返回</n-button>
<n-text strong class="bar-title-text">{{ pageTitle }}</n-text>
</div>
<div v-if="page === 'download'" class="bar-search">
<n-input
v-model:value="catalogQuery"
@@ -553,6 +738,10 @@ onUnmounted(stopProfileWatch);
<n-text v-if="downloadRef?.searchHint" class="search-hint" depth="3">{{ downloadRef.searchHint }}</n-text>
</div>
<div class="bar-actions">
<template v-if="page === 'settings'">
<n-button secondary :disabled="settingsBusy" @click="restoreCurrent">还原设置</n-button>
<n-button type="primary" :loading="settingsBusy" @click="saveSettings">保存</n-button>
</template>
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload(true)">刷新</n-button>
</div>
</n-layout-header>
@@ -562,37 +751,73 @@ onUnmounted(stopProfileWatch);
ref="downloadRef"
:token="token"
:device="sessionDevice()"
@started="page = 'progress'"
@play="openPlay"
/>
<PlayerPage
v-if="page === 'play' && playing"
:token="token"
:device="sessionDevice()"
:drama-id="playing.id"
/>
<DownloadProgressPage v-show="page === 'progress'" :active="downloadState.active" :done="downloadState.done" />
<div v-show="page === 'settings'" class="settings-page">
<n-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
<SettingsPanel v-model:tab="settingsTab" :draft="draft" @choose="chooseDownloadDirectory" />
</div>
<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 class="profile-sheet">
<div class="profile-head">
<n-avatar round :size="64" :src="avatarSrc" />
<div class="profile-id">
<div class="profile-name-row">
<span class="profile-name">{{ profile.nickname || "未命名" }}</span>
<n-tag v-if="profile.badge" size="small" :bordered="false" type="success">{{ profile.badge }}</n-tag>
</div>
<span class="profile-sub">{{ profile.email || "未绑定邮箱" }}</span>
</div>
<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>
<div class="profile-stats">
<div v-for="item in statFields" :key="item.key" class="profile-stat">
<span class="profile-stat-value">{{ profileNumber(item.key) }}</span>
<span class="profile-stat-label">{{ item.label }}</span>
</div>
</div>
<div class="profile-block">
<div class="profile-block-title">账号</div>
<div v-for="item in accountFields" :key="item.key" class="profile-row">
<span class="profile-row-label">{{ item.label }}</span>
<span class="profile-row-value">{{ profileText(item.key) }}</span>
</div>
</div>
<div class="profile-block">
<div class="profile-block-title">绑定</div>
<div class="profile-binds">
<span v-for="item in bindFields" :key="item.key" class="profile-bind" :class="{ on: profileBound(item.key) }">{{ item.label }}</span>
</div>
</div>
</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-content content-style="padding: 28px 20px">
<n-layout v-else class="shell login-shell" position="absolute">
<n-layout-content>
<div class="login-center">
<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>
@@ -633,13 +858,14 @@ onUnmounted(stopProfileWatch);
</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-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">
<div class="login-actions">
<n-button type="primary" attr-type="submit" :loading="busy">登录</n-button>
</div>
</n-form>
</div>
</div>
</n-layout-content>
</n-layout>
<n-tooltip v-if="!profile" placement="left">
@@ -657,97 +883,50 @@ onUnmounted(stopProfileWatch);
</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>
<n-modal v-model:show="settingsOpen" preset="card" title="设置" style="width: 640px">
<div class="settings-modal-body">
<n-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
<SettingsPanel v-model:tab="settingsTab" :draft="draft" @choose="chooseDownloadDirectory" />
</div>
<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 class="modal-actions">
<n-button :disabled="settingsBusy" @click="restoreCurrent">还原设置</n-button>
<span class="modal-spacer"></span>
<n-button :disabled="settingsBusy" @click="settingsOpen = false">取消</n-button>
<n-button type="primary" :loading="settingsBusy" @click="saveSettings">保存</n-button>
</div>
</template>
</n-modal>
<n-modal
v-model:show="closeAsk"
preset="dialog"
title="关闭程序"
positive-text="确定"
negative-text="取消"
:mask-closable="false"
@positive-click="confirmQuit"
>
还有 {{ closeCount }} 个未完成的下载,关闭后会中断。确定要关闭吗?
</n-modal>
</n-config-provider>
</template>
<style>
html, body, #app { height: 100%; margin: 0; }
html { color-scheme: dark; }
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.28) transparent;
}
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.36); }
*::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
*::-webkit-scrollbar-corner { background: transparent; }
.app { height: 100%; position: relative; }
.shell .n-layout-scroll-container { overflow-y: auto; }
.shell .main-pane { height: 100%; min-height: 0; }
@@ -814,10 +993,77 @@ html, body, #app { height: 100%; margin: 0; }
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; }
.profile-page { height: 100%; overflow: auto; box-sizing: border-box; padding: 20px; }
.profile-sheet {
max-width: 640px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.profile-head { display: flex; align-items: center; gap: 16px; padding: 20px 16px 16px; }
.profile-id { flex: 1; min-width: 0; }
.profile-name-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.profile-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 600;
line-height: 1.2;
}
.profile-sub {
display: block;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.profile-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.profile-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 4px;
}
.profile-stat + .profile-stat { border-left: 1px solid rgba(255, 255, 255, 0.08); }
.profile-stat-value { font-size: 18px; font-weight: 600; line-height: 1.2; }
.profile-stat-label { font-size: 12px; color: rgba(255, 255, 255, 0.45); }
.profile-block { border-top: 1px solid rgba(255, 255, 255, 0.08); }
.profile-block-title { padding: 12px 16px 0; font-size: 13px; color: rgba(255, 255, 255, 0.45); }
.profile-row { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 8px 16px; }
.profile-row-label { width: 72px; flex: none; color: rgba(255, 255, 255, 0.72); }
.profile-row-value {
flex: 1;
min-width: 0;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-binds { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 16px 16px; }
.profile-bind {
min-width: 72px;
height: 28px;
padding: 0 10px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.profile-bind.on { background: rgba(99, 226, 183, 0.16); color: #63e2b7; }
.logout-icon {
width: 18px;
height: 18px;
@@ -833,6 +1079,8 @@ html, body, #app { height: 100%; margin: 0; }
display: flex; align-items: center; justify-content: space-between;
height: auto; padding: 12px 20px;
}
.bar-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.bar-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-search {
position: absolute;
left: 50%;
@@ -844,18 +1092,71 @@ html, body, #app { height: 100%; margin: 0; }
}
.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; }
.nav-label { display: inline-flex; align-items: center; gap: 8px; }
.nav-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #63e2b7;
color: #101014;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.login-shell,
.login-shell > .n-layout-scroll-container,
.login-shell .n-layout-content,
.login-shell .n-layout-content > .n-layout-scroll-container { height: 100%; }
.login-center {
height: 100%;
display: flex;
align-items: safe center;
justify-content: center;
box-sizing: border-box;
padding: 28px 20px;
}
.card { width: 100%; max-width: 560px; }
.login-actions { display: flex; justify-content: center; margin-top: 16px; }
.login-actions .n-button { min-width: 180px; }
.settings-button { position: fixed; right: 20px; bottom: 20px; z-index: 5; }
.gear-icon { width: 18px; height: 18px; display: block; }
.settings-modal-body { max-height: min(58vh, 520px); overflow: auto; padding-right: 4px; }
.modal-actions { display: flex; align-items: center; gap: 8px; }
.modal-spacer { flex: 1; }
.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; }
.device-line .n-select,
.device-line .n-input { 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; }
.settings-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 8px 20px 20px;
}
.settings-page > .n-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.settings-page .n-tabs-pane-wrapper { flex: 1; min-height: 0; overflow: auto; }
.settings-form { max-width: 640px; padding-top: 8px; }
.settings-form .n-form-item { margin-bottom: 16px; }
.field-label { display: inline-flex; align-items: center; gap: 6px; }
.worker-input { width: 148px; }
.worker-input .n-input__input-el { text-align: center; }
.help-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.28);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 1;
color: rgba(255, 255, 255, 0.65);
cursor: help;
}
</style>
+3 -2
View File
@@ -16,17 +16,17 @@ declare module 'vue' {
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']
NInputNumber: typeof import('naive-ui')['NInputNumber']
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']
NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NSelect: typeof import('naive-ui')['NSelect']
@@ -34,6 +34,7 @@ declare module 'vue' {
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip']
}
+13
View File
@@ -1,4 +1,17 @@
import { createApp } from "vue";
import App from "./App.vue";
window.addEventListener("contextmenu", (event) => event.preventDefault());
window.addEventListener("keydown", (event) => {
const key = event.key.toLowerCase();
const ctrl = event.ctrlKey || event.metaKey;
const refresh = event.key === "F5" || (ctrl && key === "r");
const devtools = event.key === "F12" || (ctrl && event.shiftKey && (key === "i" || key === "j" || key === "c"));
const viewSource = ctrl && key === "u";
if (refresh || devtools || viewSource) {
event.preventDefault();
event.stopPropagation();
}
});
createApp(App).mount("#app");
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
interface Job {
id: string;
title: string;
image: string;
status: string;
episode_count: number;
done_count: number;
progress: number;
speed: string;
stage: string;
completed_at: string;
error: string;
}
const props = defineProps<{
active: Job[];
done: Job[];
}>();
const tab = ref<"active" | "done">("active");
const holding = ref(false);
const resumeAll = computed(
() => props.active.length > 0 && props.active.every((job) => job.status === "paused" || job.status === "failed"),
);
function coverOf(image: string) {
if (!image) return "";
if (image.startsWith("/api/") || image.startsWith("http")) return image;
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
}
async function holdAll() {
if (!props.active.length || holding.value) return;
holding.value = true;
try {
await fetch(resumeAll.value ? "/api/downloads/resume-all" : "/api/downloads/pause-all", { method: "POST" });
} finally {
holding.value = false;
}
}
async function act(id: string, action: string) {
await fetch(`/api/downloads/${id}/${action}`, { method: "POST" });
}
async function remove(id: string) {
await fetch(`/api/downloads/${id}`, { method: "DELETE" });
}
</script>
<template>
<div class="progress-page">
<n-tabs v-model:value="tab" type="line" size="small">
<template #suffix>
<n-button
v-if="tab === 'active'"
class="bulk-hold"
size="small"
secondary
:disabled="!props.active.length"
:loading="holding"
@click="holdAll"
>
<template #icon>
<svg v-if="resumeAll" class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z" /></svg>
<svg v-else class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" /></svg>
</template>
{{ resumeAll ? "继续" : "暂停" }}
</n-button>
</template>
<n-tab-pane name="active" :tab="`正在下载${props.active.length ? ` ${props.active.length}` : ''}`">
<div v-if="!props.active.length" class="progress-empty">没有正在下载的剧</div>
<article v-for="job in props.active" :key="job.id" class="job">
<img v-if="coverOf(job.image)" class="job-cover" :src="coverOf(job.image)" alt="" />
<div v-else class="job-cover"></div>
<div class="job-main">
<div class="job-line">
<n-text strong class="job-title">{{ job.title }}</n-text>
<n-text depth="3">{{ job.done_count }}/{{ job.episode_count }} 集</n-text>
<n-text v-if="job.stage" depth="3">{{ job.stage }}</n-text>
<n-text v-else-if="job.speed" depth="3">{{ job.speed }}</n-text>
<n-text v-else-if="job.status === 'paused'" depth="3">已暂停</n-text>
<n-text v-else-if="job.status === 'failed'" depth="3">失败</n-text>
<n-text v-else-if="job.status === 'queued'" depth="3">排队中</n-text>
</div>
<n-progress type="line" :percentage="job.progress" :show-indicator="false" :height="6" />
<n-text v-if="job.error" class="job-error" type="error">{{ job.error }}</n-text>
</div>
<div class="job-actions">
<n-tooltip v-if="job.status === 'paused' || job.status === 'failed'">
<template #trigger>
<n-button quaternary circle aria-label="继续" @click="act(job.id, 'resume')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z" /></svg></template>
</n-button>
</template>
继续
</n-tooltip>
<n-tooltip v-else>
<template #trigger>
<n-button quaternary circle aria-label="暂停" @click="act(job.id, 'pause')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" /></svg></template>
</n-button>
</template>
暂停
</n-tooltip>
<n-tooltip>
<template #trigger>
<n-button quaternary circle aria-label="取消" @click="act(job.id, 'cancel')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7 2.9 18.3 9.2 12 2.9 5.7 4.3 4.3l6.3 6.3 6.3-6.3z" /></svg></template>
</n-button>
</template>
取消
</n-tooltip>
</div>
</article>
</n-tab-pane>
<n-tab-pane name="done" tab="已完成">
<div v-if="!props.done.length" class="progress-empty">还没有完成的下载</div>
<article v-for="job in props.done" :key="job.id" class="job">
<img v-if="coverOf(job.image)" class="job-cover" :src="coverOf(job.image)" alt="" />
<div v-else class="job-cover"></div>
<div class="job-main">
<div class="job-line">
<n-text strong class="job-title">{{ job.title }}</n-text>
<n-text depth="3">{{ job.episode_count }} 集</n-text>
</div>
<n-text depth="3">{{ job.completed_at }}</n-text>
</div>
<div class="job-actions">
<n-tooltip>
<template #trigger>
<n-button quaternary circle aria-label="打开目录" @click="act(job.id, 'open')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8z" /></svg></template>
</n-button>
</template>
打开目录
</n-tooltip>
<n-tooltip>
<template #trigger>
<n-button quaternary circle aria-label="删除" @click="remove(job.id)">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4zm1 6h2v8h-2zm4 0h2v8h-2zM7 9h2v8H7z" /></svg></template>
</n-button>
</template>
删除
</n-tooltip>
</div>
</article>
</n-tab-pane>
</n-tabs>
</div>
</template>
<style>
.progress-page { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; padding: 8px 20px 20px; }
.progress-page > .n-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.progress-page .n-tabs-pane-wrapper { flex: 1; min-height: 0; overflow: auto; }
.bulk-hold { margin-left: 12px; }
.progress-empty { padding: 28px 0; color: rgba(255, 255, 255, 0.45); }
.job {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.job-cover { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; background: #2a2a2a; flex: none; }
.job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.job-line { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.job-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-actions { display: flex; gap: 4px; flex: none; }
.job-icon { width: 18px; height: 18px; display: block; }
.job-error { font-size: 12px; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import Hls from "hls.js";
interface Episode {
cid: number;
idx: number;
title: string;
playable: boolean;
}
const props = defineProps<{
token: string;
device: object | null;
dramaId: number;
}>();
const episodes = ref<Episode[]>([]);
const current = ref<number | null>(null);
const loading = ref(false);
const error = ref("");
const videoRef = ref<HTMLVideoElement | null>(null);
let hls: Hls | null = null;
const currentTitle = computed(() => episodes.value.find((item) => item.cid === current.value)?.title || "");
async function load() {
loading.value = true;
error.value = "";
try {
const response = await fetch("/api/play/episodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: props.token, device: props.device, vid: props.dramaId }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
error.value = typeof payload.detail === "string" ? payload.detail : "没有读到分集";
return;
}
episodes.value = Array.isArray(payload.episodes) ? payload.episodes : [];
const first = episodes.value.find((item) => item.playable);
if (!first) {
error.value = "缓存和当前账号都没有可播放的集";
return;
}
select(first);
} catch (exc) {
error.value = exc instanceof Error ? exc.message : "没有读到分集";
} finally {
loading.value = false;
}
}
function select(episode: Episode) {
if (!episode.playable || episode.cid === current.value) return;
current.value = episode.cid;
error.value = "";
void nextTick(() => attach(episode.cid));
}
function attach(cid: number) {
const video = videoRef.value;
if (!video) return;
stop();
const url = `/api/play/${props.dramaId}/${cid}/index.m3u8`;
if (Hls.isSupported()) {
const player = new Hls();
hls = player;
player.loadSource(url);
player.attachMedia(video);
player.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => undefined);
});
player.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) error.value = "这一集播放失败";
});
return;
}
video.src = url;
video.play().catch(() => undefined);
}
function stop() {
hls?.destroy();
hls = null;
if (videoRef.value) {
videoRef.value.removeAttribute("src");
videoRef.value.load();
}
}
onMounted(load);
onUnmounted(stop);
</script>
<template>
<div class="play-page">
<div class="play-stage">
<div class="play-video-wrap">
<video ref="videoRef" class="play-video" controls playsinline></video>
<div v-if="loading || error" class="play-status">{{ loading ? "正在读取分集" : error }}</div>
</div>
<div v-if="currentTitle" class="play-now">{{ currentTitle }}</div>
</div>
<aside class="play-side">
<div class="play-side-title">选集</div>
<div class="play-episodes">
<button
v-for="episode in episodes"
:key="episode.cid"
type="button"
class="play-episode"
:class="{ current: episode.cid === current }"
:disabled="!episode.playable"
@click="select(episode)"
>
<span class="play-episode-title">{{ episode.title }}</span>
<span v-if="!episode.playable" class="play-episode-lock">无地址</span>
</button>
<div v-if="!loading && episodes.length === 0" class="play-empty">没有分集</div>
</div>
</aside>
</div>
</template>
<style>
.play-page { height: 100%; min-height: 0; display: flex; overflow: hidden; background: #101014; }
.play-stage { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.play-video-wrap { position: relative; flex: 1; min-height: 0; background: #000; }
.play-video { display: block; width: 100%; height: 100%; background: #000; }
.play-status {
position: absolute;
left: 16px;
right: 16px;
bottom: 56px;
color: rgba(255, 255, 255, 0.8);
text-align: center;
}
.play-now {
flex: none;
padding: 8px 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.72);
}
.play-side {
width: 280px;
flex: none;
min-height: 0;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.play-side-title { flex: none; padding: 12px 16px 8px; font-weight: 600; }
.play-episodes { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 12px; }
.play-episode {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
margin: 0 0 4px;
padding: 6px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.play-episode.current { background: rgba(99, 226, 183, 0.16); }
.play-episode:disabled { color: rgba(255, 255, 255, 0.35); cursor: default; }
.play-episode-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.play-episode-lock { flex: none; font-size: 12px; }
.play-empty { padding: 8px 10px; color: rgba(255, 255, 255, 0.45); }
</style>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
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;
}
interface SettingsDraft {
adbMode: "device" | "settings";
custom: DeviceProfile;
proxy: ProxySettings;
workers: number;
directory: string;
}
const tab = defineModel<"device" | "proxy" | "download">("tab", { required: true });
const props = defineProps<{ draft: SettingsDraft }>();
const emit = defineEmits<{ choose: [] }>();
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 setDraftField(key: keyof DeviceProfile, value: string | boolean) {
props.draft.custom = { ...props.draft.custom, [key]: value };
}
</script>
<template>
<n-tabs v-model:value="tab" type="line" size="small">
<n-tab-pane name="device" tab="设备信息" display-directive="if">
<n-form class="settings-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">保存后下次登录生效。当前这次登录已经绑定的设备信息不会中途更换。</n-text>
<n-text class="hint" depth="3">从设备读取时会带上品牌、型号、系统版本、设备 ID 和已安装的 App 版本。指纹和语言仍用当前设置里的值。</n-text>
</n-form>
</n-tab-pane>
<n-tab-pane name="proxy" tab="代理" display-directive="if">
<n-form class="settings-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>
</n-tab-pane>
<n-tab-pane name="download" tab="下载" display-directive="if">
<n-form class="settings-form" label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item label="下载目录">
<div class="device-line">
<n-input v-model:value="draft.directory" placeholder="下载目录" />
<n-button @click="emit('choose')">选择</n-button>
</div>
</n-form-item>
<n-form-item>
<template #label>
<span class="field-label">
同时下载
<n-tooltip>
<template #trigger>
<span class="help-dot" tabindex="0">?</span>
</template>
同时进行的下载任务数,范围 1 到 8,默认 2。调低后,多出来的任务会在当前这部下完后停下来。
</n-tooltip>
</span>
</template>
<n-input-number v-model:value="draft.workers" class="worker-input" button-placement="both" :min="1" :max="8" :precision="0" />
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
</template>
+134 -16
View File
@@ -32,6 +32,10 @@ let searchCursor = 0;
let lastQuery = "";
let revealGeneration = 0;
const flashSerial = ref(0);
const directory = ref("");
const starting = ref(false);
const footError = ref("");
const emit = defineEmits<{ started: []; play: [Drama] }>();
const scrollEl = ref<HTMLElement | null>(null);
function coverOf(image: string) {
@@ -275,8 +279,62 @@ async function find(keyword = query.value) {
if (generation !== revealGeneration) return;
flashKey.value = key;
}
onMounted(reload);
defineExpose({ reload, busy, find, searchHint, clearSearch });
onMounted(() => {
void reload();
void loadDirectory();
});
defineExpose({ reload, busy, find, searchHint, clearSearch, loadDirectory });
async function loadDirectory() {
const response = await fetch("/api/downloads");
if (!response.ok) return;
const payload = await response.json();
directory.value = payload.directory || "";
}
async function chooseDirectory() {
footError.value = "";
const response = await fetch("/api/downloads/pick", { method: "POST" });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const detail = typeof payload.detail === "string" ? payload.detail : "";
if (detail) footError.value = detail;
return;
}
const payload = await response.json();
if (payload.directory) directory.value = payload.directory;
}
async function startDownload() {
const seen = new Set<number>();
const items: { id: number; title: string; image: string }[] = [];
for (const section of sections.value) {
for (const item of section.items) {
if (!selected.value.has(item.id) || seen.has(item.id)) continue;
seen.add(item.id);
items.push({ id: item.id, title: item.title, image: item.image });
}
}
if (!items.length) return;
starting.value = true;
footError.value = "";
try {
const response = await fetch("/api/downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: props.token, device: props.device, items }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(typeof payload.detail === "string" ? payload.detail : "加入下载失败");
}
emit("started");
} catch (exc) {
footError.value = exc instanceof Error ? exc.message : "加入下载失败";
} finally {
starting.value = false;
}
}
</script>
<template>
@@ -286,22 +344,32 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
<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-tooltip placement="top">
<template #trigger>
<n-button quaternary circle :aria-label="isExpanded(section.key) ? '收起' : '展开'" @click="toggleExpanded(section.key)">
<span class="chevron" :class="{ open: isExpanded(section.key) }">▸</span>
</n-button>
</template>
{{ isExpanded(section.key) ? "收起" : "展开" }}
</n-tooltip>
<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>
<n-tooltip placement="top">
<template #trigger>
<n-checkbox
:checked="allChecked(section)"
:indeterminate="someChecked(section)"
:disabled="section.items.length === 0"
@update:checked="toggleSection(section, $event)"
>
全选
</n-checkbox>
</template>
{{ allChecked(section) ? "取消全选" : "全选" }}
</n-tooltip>
</div>
<div v-if="isExpanded(section.key)" class="category-body">
<n-text v-if="section.note" depth="3">{{ section.note }}</n-text>
@@ -316,9 +384,24 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
>
<div class="cover">
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
<div class="cover-play-layer">
<n-tooltip placement="top">
<template #trigger>
<button type="button" class="cover-play" aria-label="播放" @click.stop="emit('play', item)">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9 7.5v9l8-4.5z" /></svg>
</button>
</template>
播放
</n-tooltip>
</div>
</div>
<div class="pick" @click.stop>
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
<n-tooltip placement="left">
<template #trigger>
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
</template>
{{ selected.has(item.id) ? "取消选择" : "选择" }}
</n-tooltip>
</div>
<div class="drama-title">{{ item.title }}</div>
<div
@@ -335,7 +418,13 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
</div>
<footer class="download-foot">
<n-text>已选 {{ selected.size }} 部</n-text>
<div class="foot-actions">
<n-text class="foot-dir" depth="3" :title="directory">{{ directory || "未设置下载目录" }}</n-text>
<n-button secondary size="small" @click="chooseDirectory">选择</n-button>
<n-button type="primary" size="small" :disabled="selected.size === 0" :loading="starting" @click="startDownload">下载</n-button>
</div>
</footer>
<n-text v-if="footError" class="foot-error" type="error">{{ footError }}</n-text>
</div>
</template>
@@ -355,12 +444,15 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
border-top: 1px solid rgba(255, 255, 255, 0.12);
background: #18181c;
}
.foot-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
.foot-dir { max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.foot-error { display: block; padding: 0 20px 8px; text-align: right; }
.catalog-error { margin: 8px 20px 12px; }
.category { margin-bottom: 8px; }
.category-bar {
position: sticky;
top: 0;
z-index: 1;
z-index: 3;
display: flex;
align-items: center;
gap: 8px;
@@ -395,9 +487,35 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.cover { background: #2a2a2a; }
.cover { position: relative; background: #2a2a2a; }
.cover img { display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; }
.cover:not(:has(img)) { aspect-ratio: 3 / 4; }
.cover-play-layer {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.cover-play {
position: relative;
pointer-events: auto;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
}
.cover-play svg { width: 22px; height: 22px; display: block; }
.cover-play:hover { background: rgba(0, 0, 0, 0.72); }
.pick {
position: absolute;
top: 8px;