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
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RaptDrama</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1849
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "raptdrama-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"naive-ui": "^2.45.3",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.16",
"vue-tsc": "^3.3.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+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;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+49
View File
@@ -0,0 +1,49 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { NaiveUiResolver } from "unplugin-vue-components/resolvers";
// @ts-expect-error type error without @types/node package
import process from "node:process";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(() => ({
plugins: [
vue(),
AutoImport({
imports: ["vue"],
dts: "src/auto-imports.d.ts",
}),
Components({
resolvers: [NaiveUiResolver()],
dts: "src/components.d.ts",
}),
],
build: {
outDir: "../dist/ui",
emptyOutDir: true,
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 8799,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 8800,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));