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
+7
View File
@@ -6,3 +6,10 @@ __pycache__/
raptdrama_passion_full.json raptdrama_passion_full.json
raptdrama_catalog.json raptdrama_catalog.json
video/ video/
cache/
node_modules/
dist/
src-tauri/target/
src-tauri/gen/schemas
vendor/webview2/
server/.venv/
+247
View File
@@ -0,0 +1,247 @@
{
"name": "raptdrama",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "raptdrama",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.5.tgz",
"integrity": "sha512-YbeJ6tctNoo40purO4u3Eeo3RVqmsjJr0NoKQRu+4pvO4gMLzkjta7x0k96SxSLmx+rQ+8um1ThOlw+HLpgFug==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.5",
"@tauri-apps/cli-darwin-x64": "2.11.5",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.5",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.5",
"@tauri-apps/cli-linux-arm64-musl": "2.11.5",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.5",
"@tauri-apps/cli-linux-x64-gnu": "2.11.5",
"@tauri-apps/cli-linux-x64-musl": "2.11.5",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.5",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.5",
"@tauri-apps/cli-win32-x64-msvc": "2.11.5"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.5.tgz",
"integrity": "sha512-tggOiVOohjIHdiElbjeBfB41s6cmfsq+ZQ0PX0fEtCdsDdZ9cCi54e5gNK7tdPbR6JbdDe7RA4kbQdyyBUUk/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.5.tgz",
"integrity": "sha512-SrO+KCbqvG1IvVPdlzQX4GEGZRQDISrmDY9bGcW68DLVFBCC06H+Loz6oyUZETUxsSMR6B4oDfvbQVgD4hxsqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.5.tgz",
"integrity": "sha512-qLrOaCa8hLD5QVPQRjAgOXvhlTFKXmTliVF773GgvADYcWZSdmncnF5zBFAkdu1uQK8KfQbDlBUwqQYZqBfBRQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.5.tgz",
"integrity": "sha512-Tq32xpQjiQEdIrvwDq7lwHhXemn070CXWdbCvr9rdi9rsv9NFTbQ6izVSIfNE+59QaerxuwtUtZ5IxDNWaoYBw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.5.tgz",
"integrity": "sha512-+OdWKj5Aq+C8HXypQ4t926V8cpVnc0ZcJIdhTXWjVAJHkaNuo8EuQR6KRZKHWIbJJgKdMPCDosvwzIFaBBqHKQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.5.tgz",
"integrity": "sha512-Q0V2hjpllyDW8dWQZSsITuQDfcY9BpBZuhXkMa2T5AFLZAn+FJPXQSyvnxWNVxRowdQ59Xn0QVtGm6HEHNUEvw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.5.tgz",
"integrity": "sha512-Xz1s87gFjZJZMQKAhLa77YeiCJM6yVog3f9OxDgVd/KxDIoB1dA5V2xOnMM+SRkOGk9juwIBqesVAjV0kUB0DA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.5.tgz",
"integrity": "sha512-6A88wqAZXZHgMaBrGiy/Bpa4GgUnftXM7uquKOeOXCuK78/XdBxEECicK/1oHgrT179XBFOaA6lQEHysjRmGig==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.5.tgz",
"integrity": "sha512-sRGgF/ObRfLcS3PtUdyTbJtkGq3E+PNZTknvAURwMNUtHw9DU/BF6S5si43y9lS5yEO9bWJCA+GGYvnn6cphQw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.5.tgz",
"integrity": "sha512-bHOv/yxqfKpI7ioIFQi5wOfZ7jkbPzJD/UhXY52I/hrmE3qNh71cohIPyqZB5GQn3LZeJWdCUOqmIg/w6XX+Wg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.5.tgz",
"integrity": "sha512-vaaOmavqdCeS7oMib2banBZemgrC5v54tSkwoBV+bxrXQZhQZrOwE2EO2i4x+CHNtvQUM/6bSs3iymZMAsycWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "raptdrama",
"private": true,
"version": "0.1.0",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
View File
View File
+43
View File
@@ -0,0 +1,43 @@
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from server.routes.adb import router as adb_router
from server.routes.auth import router as auth_router
from server.routes.catalog import router as catalog_router
from server.routes.covers import router as covers_router
from server.routes.device import router as device_router
from server.routes.proxy import router as proxy_router
from server.routes.session import router as session_router
def create_app() -> FastAPI:
app = FastAPI(title="RaptDrama")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(session_router)
app.include_router(adb_router)
app.include_router(device_router)
app.include_router(catalog_router)
app.include_router(covers_router)
app.include_router(proxy_router)
@app.get("/health")
def health():
return {"ok": True}
static_dir = os.environ.get("RAPTD_STATIC", "").strip()
if static_dir and Path(static_dir).is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="ui")
return app
app = create_app()
+13
View File
@@ -0,0 +1,13 @@
from collections.abc import Callable
from typing import TypeVar
from fastapi import HTTPException
T = TypeVar("T")
def call(action: Callable[[], T]) -> T:
try:
return action()
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+23
View File
@@ -0,0 +1,23 @@
import os
import socket
import uvicorn
def listen_port() -> int:
chosen = os.environ.get("RAPTD_PORT", "").strip()
if chosen:
return int(chosen)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def main() -> None:
port = listen_port()
print(f"http://127.0.0.1:{port}", flush=True)
uvicorn.run("server.app:app", host="127.0.0.1", port=port)
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn>=0.32
httpx[socks]>=0.28
View File
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from server.errors import call
from server.services.adb import Adb
router = APIRouter(prefix="/api/adb")
@router.get("/devices")
def devices():
return call(lambda: {"devices": Adb.locate().list_devices()})
+28
View File
@@ -0,0 +1,28 @@
from fastapi import APIRouter
from server.errors import call
from server.schemas import AdbBody, GuestBody, PasswordBody, device_payload
from server.services.login import login
router = APIRouter(prefix="/api/login")
@router.post("/guest")
def login_guest(body: GuestBody | None = None):
raw = device_payload(body.device) if body else None
return call(lambda: login.guest(raw))
@router.post("/password")
def login_password(body: PasswordBody):
return call(lambda: login.password(body.email, body.password, device_payload(body.device)))
@router.post("/adb")
def login_adb(body: AdbBody):
return call(lambda: login.adb(
body.serial,
body.adb,
body.device_source,
device_payload(body.device),
))
+27
View File
@@ -0,0 +1,27 @@
import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from server.schemas import SessionBody, device_payload
from server.services.catalog import catalog
router = APIRouter(prefix="/api")
@router.post("/catalog")
async def load_catalog(body: SessionBody):
token = body.token.strip()
if not token:
raise HTTPException(status_code=400, detail="请先登录")
raw_device = device_payload(body.device)
async def generate():
try:
async for event in catalog.stream(token, raw_device):
yield json.dumps(event, ensure_ascii=False) + "\n"
except Exception as exc:
message = str(exc) or "获取清单失败"
yield json.dumps({"type": "error", "message": message}, ensure_ascii=False) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Response
from server.services.covers import covers
router = APIRouter(prefix="/api/covers")
@router.get("/{digest}")
def cover(digest: str):
try:
data, media = covers.read(digest)
except RuntimeError as exc:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=str(exc)) from exc
return Response(
content=data,
media_type=media,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
+10
View File
@@ -0,0 +1,10 @@
from fastapi import APIRouter
from server.services.device import DeviceProfile
router = APIRouter(prefix="/api/device")
@router.get("/defaults")
def defaults():
return DeviceProfile.builtin().as_dict()
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter
from server.errors import call
from server.schemas import ProxyBody
from server.services.http import http
router = APIRouter(prefix="/api")
@router.post("/proxy")
def set_proxy(body: ProxyBody):
return call(lambda: _apply(body))
def _apply(body: ProxyBody) -> dict:
http.configure_proxy(body.model_dump())
return {"ok": True}
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, HTTPException
from server.errors import call
from server.schemas import SessionBody, device_payload
from server.services.login import login
from server.services.profile_poller import profile_poller
from server.services.sessions import sessions
router = APIRouter(prefix="/api")
@router.post("/me")
def me(body: SessionBody):
return call(lambda: login.current(body.token, device_payload(body.device)))
@router.get("/profile")
def profile():
data = sessions.snapshot()
if data is None:
raise HTTPException(status_code=404, detail="未登录")
return data
@router.post("/logout")
def logout(body: SessionBody):
token = body.token.strip()
profile_poller.stop_if(token)
sessions.clear(token)
return {"ok": True}
+51
View File
@@ -0,0 +1,51 @@
from pydantic import BaseModel
class DeviceBody(BaseModel):
brand: str = ""
model: str = ""
os_version: str = ""
device_type: str = ""
physical: bool | None = None
device_id: str = ""
fingerprint: str = ""
app_version: str = ""
app_build: str = ""
language: str = ""
class GuestBody(BaseModel):
device: DeviceBody | None = None
class PasswordBody(BaseModel):
email: str
password: str
device: DeviceBody | None = None
class AdbBody(BaseModel):
serial: str = ""
adb: str = ""
device_source: str = "device"
device: DeviceBody | None = None
class SessionBody(BaseModel):
token: str
device: DeviceBody | None = None
class ProxyBody(BaseModel):
mode: str = "off"
scheme: str = "http"
host: str = ""
port: str = ""
username: str = ""
password: str = ""
def device_payload(body: DeviceBody | None) -> dict | None:
if body is None:
return None
return body.model_dump()
View File
+190
View File
@@ -0,0 +1,190 @@
import html
import json
import os
import re
import subprocess
import time
from pathlib import Path
from server.services.device import DeviceProfile
PACKAGE = "com.leivideo.raptdrama"
PREFS = f"/data/data/{PACKAGE}/shared_prefs/FlutterSharedPreferences.xml"
REPO_ROOT = Path(__file__).resolve().parents[2]
class Adb:
def __init__(self, executable: str) -> None:
self.executable = executable
@classmethod
def locate(cls, explicit: str = "") -> "Adb":
if explicit:
if not os.path.isfile(explicit):
raise RuntimeError(f"找不到 adb:{explicit}")
return cls(explicit)
name = "adb.exe" if os.name == "nt" else "adb"
path = REPO_ROOT / "tools" / "platform-tools" / name
if not path.is_file():
raise RuntimeError(f"项目内没有 adb:{path}")
return cls(str(path))
def list_devices(self) -> list[dict]:
proc = self._run(["devices", "-l"])
if proc.returncode not in (0, None):
err = proc.stderr.decode("utf-8", "replace").strip() or "adb devices 失败"
raise RuntimeError(err)
items = []
for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]:
parts = line.split()
if len(parts) < 2 or parts[1] not in ("device", "unauthorized", "offline"):
continue
serial, state = parts[0], parts[1]
model = ""
for part in parts[2:]:
if part.startswith("model:"):
model = part.split(":", 1)[1].replace("_", " ")
if state == "device" and not model:
_, text, _ = self._shell(serial, ["getprop", "ro.product.model"], timeout=8)
model = text.strip()
items.append({
"serial": serial,
"model": model or "未知型号",
"rooted": self._rooted(serial) if state == "device" else False,
"state": state,
})
return items
def read_profile(self, serial: str) -> DeviceProfile:
serial = self._ready(serial)
profile = DeviceProfile.builtin()
brand = self._prop(serial, "ro.product.brand") or self._prop(serial, "ro.product.manufacturer")
model = self._prop(serial, "ro.product.model")
os_version = self._prop(serial, "ro.build.version.release")
characteristics = self._prop(serial, "ro.build.characteristics")
qemu = self._prop(serial, "ro.kernel.qemu") or self._prop(serial, "ro.boot.qemu")
_, android_id, err = self._shell(serial, ["settings", "get", "secure", "android_id"], timeout=8)
if err == "timeout":
raise RuntimeError("读取设备信息超时,请确认设备仍连接后重试。")
android_id = android_id.strip()
version_name, version_code = self._app_version(serial)
if brand:
profile.brand = brand
if model:
profile.model = model
if os_version:
profile.os_version = os_version
if version_name:
profile.app_version = version_name
if version_code:
profile.app_build = version_code
if android_id and android_id.lower() != "null":
profile.device_id = f"rd_{android_id}"
profile.device_type = "pad" if "tablet" in characteristics else "phone"
profile.physical = not (qemu == "1" or serial.startswith("emulator-"))
return profile
def read_token(self, serial: str) -> str:
serial = self._ready(serial)
if not self._app_installed(serial):
raise RuntimeError("这台设备还没有安装 RaptDrama。请先安装 App 并登录,然后再读取登录信息。")
text = self._prefs(serial)
match = re.search(r'name="flutter\.user_data"[^>]*>(.*?)</string>', text, re.S)
if not match:
raise RuntimeError("登录信息里没有 user_data,请先在 App 里登录")
user = json.loads(html.unescape(match.group(1)))
token = user.get("accessToken") or ""
if not token:
raise RuntimeError("当前账号没有 accessToken,请先在 App 里登录")
return token
def _ready(self, serial: str) -> str:
serial = (serial or "").strip()
if ":" in serial:
self._run(["connect", serial], timeout=15)
ready = self._ready_serials()
if serial not in ready:
shown = "、".join(ready) or "无"
raise RuntimeError(f"设备 {serial} 未就绪。当前已连接:{shown}")
return serial
def _ready_serials(self) -> list[str]:
proc = self._run(["devices"])
serials = []
for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serials.append(parts[0])
return serials
def _app_installed(self, serial: str) -> bool:
_, text, err = self._shell(serial, ["pm", "path", PACKAGE], timeout=8)
if err == "timeout":
raise RuntimeError("检查 App 是否安装超时,请确认设备仍连接后重试。")
return "package:" in text
def _app_version(self, serial: str) -> tuple[str, str]:
_, text, _ = self._shell(serial, ["dumpsys", "package", PACKAGE], timeout=15)
version_name = ""
version_code = ""
for line in text.splitlines():
line = line.strip()
if line.startswith("versionName="):
version_name = line.split("=", 1)[1].strip()
elif line.startswith("versionCode="):
version_code = line.split("=", 1)[1].split()[0]
return version_name, version_code
def _prop(self, serial: str, name: str) -> str:
_, text, err = self._shell(serial, ["getprop", name], timeout=8)
if err == "timeout":
raise RuntimeError("读取设备信息超时,请确认设备仍连接后重试。")
return text.strip()
def _rooted(self, serial: str) -> bool:
_, text, _ = self._shell(serial, ["id"], timeout=5)
if "uid=0" in text:
return True
for args in (["su", "-c", "id"], ["su", "0", "id"]):
_, text, _ = self._shell(serial, args, timeout=4)
if "uid=0" in text:
return True
return False
def _prefs(self, serial: str) -> str:
def usable(text: str) -> bool:
return "flutter.user_data" in text
_, text, _ = self._shell(serial, ["cat", PREFS])
if usable(text):
return text
self._run(["-s", serial, "root"], timeout=15)
if ":" in serial:
self._run(["connect", serial], timeout=15)
for _ in range(10):
time.sleep(0.5)
_, text, _ = self._shell(serial, ["cat", PREFS])
if usable(text):
return text
for args in (["su", "-c", f"cat {PREFS}"], ["su", "0", "cat", PREFS]):
_, text, _ = self._shell(serial, args, timeout=8)
if usable(text):
return text
raise RuntimeError(
"读不到应用私有目录。未 root 的设备无法获取登录信息。"
"请使用已 root 的手机,或带 root 的模拟器。"
)
def _shell(self, serial: str, args: list[str], timeout: int = 15) -> tuple[int, str, str]:
try:
proc = self._run(["-s", serial, "shell", *args], timeout=timeout)
except subprocess.TimeoutExpired:
return 1, "", "timeout"
return (
proc.returncode,
proc.stdout.decode("utf-8", "replace"),
proc.stderr.decode("utf-8", "replace"),
)
def _run(self, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run([self.executable, *args], capture_output=True, timeout=timeout)
+149
View File
@@ -0,0 +1,149 @@
import asyncio
from server.services.covers import covers
from server.services.device import DeviceProfile
from server.services.http import http
from server.services.rapt import API_BASE
from server.services.sessions import sessions
CLASSIFY_MAX_PAGES = 50
RECOMMEND_MAX_PAGES = 80
FIXED_SECTIONS = (
{"key": "new_releases", "name": "New Releases", "note": "", "loading": True},
{"key": "recommend", "name": "Recommend", "note": "", "loading": True},
)
class CatalogService:
def outlines(self, categories: list[dict]) -> list[dict]:
rows = [
{"key": f"type-{item['id']}", "name": item["label"], "note": "", "loading": True}
for item in categories
]
rows.extend(dict(item) for item in FIXED_SECTIONS)
return rows
def _categories(self, token: str, device: DeviceProfile) -> list[dict]:
data = http.request_data("POST", API_BASE + "/app/open/classifyv2", token, device, {})
rows = []
if isinstance(data, list):
for item in data:
if not isinstance(item, dict):
continue
type_id = item.get("id")
label = str(item.get("label") or "").strip()
if type_id is None or not label:
continue
rows.append({"id": type_id, "label": label})
if not rows:
raise RuntimeError("没有获取到分类")
return rows
async def stream(self, token: str, raw_device: dict | None):
token = token.strip()
if not token:
raise RuntimeError("请先登录")
device = sessions.bind(token, DeviceProfile.merge(raw_device))
categories = await asyncio.to_thread(self._categories, token, device)
yield {"type": "outline", "sections": self.outlines(categories)}
jobs = [
(
f"type-{item['id']}",
item["label"],
"/app/open/classify_video",
"POST",
lambda page, type_id=item["id"]: {"page": page, "limit": 10, "type": type_id},
CLASSIFY_MAX_PAGES,
"",
)
for item in categories
]
jobs.append(("new_releases", "New Releases", "/app/open/orgin", "GET", None, 1, ""))
jobs.append((
"recommend",
"Recommend",
"/app/open/recommend",
"POST",
lambda page: {"page": page, "limit": 10},
RECOMMEND_MAX_PAGES,
"",
))
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
async def run(job):
key, name, path, method, body, max_pages, note = job
def work():
try:
for event in self._iter_paged(key, name, token, device, path, method, body, max_pages, note):
loop.call_soon_threadsafe(queue.put_nowait, event)
except Exception as exc:
loop.call_soon_threadsafe(queue.put_nowait, self._event(key, name, [], str(exc) or "获取清单失败", True))
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
await asyncio.to_thread(work)
tasks = [asyncio.create_task(run(job)) for job in jobs]
finished = 0
try:
while finished < len(tasks):
event = await queue.get()
if event is None:
finished += 1
continue
yield event
yield {"type": "done"}
finally:
for task in tasks:
if not task.done():
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False) -> dict:
return {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done}
def _drama(self, item: dict) -> dict | None:
vid = item.get("id")
title = item.get("title")
if vid is None or not title:
return None
image = (item.get("image") or "").strip()
return {
"id": vid,
"title": title,
"image": covers.local_url(image) if image else "",
}
def _iter_paged(self, key: str, name: str, token: str, device: DeviceProfile, path: str, method: str, body, max_pages: int, note: str = ""):
seen = set()
page = 1
sent = False
while page <= max_pages:
data = http.request_data(method, API_BASE + path, token, device, body(page) if body else None)
batch = data if isinstance(data, list) else []
if not batch and isinstance(data, dict) and isinstance(data.get("list"), list):
batch = data["list"]
if not batch:
break
fresh = []
for item in batch:
if not isinstance(item, dict):
continue
drama = self._drama(item)
if drama is None or drama["id"] in seen:
continue
seen.add(drama["id"])
fresh.append(drama)
if not fresh:
break
sent = True
yield self._event(key, name, fresh)
if body is None:
break
page += 1
yield self._event(key, name, [], note, True)
catalog = CatalogService()
+51
View File
@@ -0,0 +1,51 @@
import hashlib
import re
import threading
from pathlib import Path
from server.services.http import http
CACHE_DIR = Path(__file__).resolve().parents[2] / "cache" / "covers"
_lock = threading.Lock()
class CoverCache:
def local_url(self, remote: str) -> str:
remote = (remote or "").strip()
if not remote.startswith(("http://", "https://")):
return remote
digest = hashlib.sha256(remote.encode("utf-8")).hexdigest()
CACHE_DIR.mkdir(parents=True, exist_ok=True)
meta = CACHE_DIR / f"{digest}.url"
if not meta.is_file():
meta.write_text(remote, encoding="utf-8")
return f"/api/covers/{digest}"
def read(self, digest: str) -> tuple[bytes, str]:
if not re.fullmatch(r"[0-9a-f]{64}", digest):
raise RuntimeError("封面不存在")
meta = CACHE_DIR / f"{digest}.url"
body = CACHE_DIR / digest
if not meta.is_file():
raise RuntimeError("封面不存在")
if body.is_file() and body.stat().st_size > 0:
return body.read_bytes(), _media_type(body)
remote = meta.read_text(encoding="utf-8").strip()
data, media = http.get_bytes(remote)
with _lock:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
body.write_bytes(data)
(CACHE_DIR / f"{digest}.type").write_text(media, encoding="utf-8")
return data, media
def _media_type(path: Path) -> str:
sidecar = Path(f"{path}.type")
if sidecar.is_file():
media = sidecar.read_text(encoding="utf-8").strip()
if media.startswith("image/"):
return media
return "image/jpeg"
covers = CoverCache()
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import asdict, dataclass
@dataclass
class DeviceProfile:
brand: str
model: str
os_version: str
device_type: str
physical: bool
device_id: str
fingerprint: str
app_version: str
app_build: str
language: str
@classmethod
def builtin(cls) -> "DeviceProfile":
return cls(
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",
)
@classmethod
def merge(cls, raw: dict | None) -> "DeviceProfile":
profile = cls.builtin()
values = asdict(profile)
for key, value in (raw or {}).items():
if key not in values or value is None:
continue
if isinstance(value, str):
value = value.strip()
if not value:
continue
if key == "physical" and isinstance(value, str):
value = value.lower() in ("1", "true", "yes")
values[key] = value
values["physical"] = bool(values["physical"])
return cls(**values)
def as_dict(self) -> dict:
return asdict(self)
def headers(self) -> dict[str, str]:
physical = "true" if self.physical else "false"
return {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": (
f"RaptDrama/{self.app_version} "
f"(Android {self.os_version}; {self.model}; {self.brand})"
),
"x-device-fingerprint": self.fingerprint,
"x-os-version": str(self.os_version),
"x-device-physical": physical,
"x-device-model": self.model,
"accept-language": self.language,
"accept-language-custom": self.language,
"x-os-name": "Android",
"x-device-brand": self.brand,
"x-device-id": self.device_id,
"x-app-build": str(self.app_build),
"x-device-type": self.device_type,
"x-app-version": self.app_version,
}
+142
View File
@@ -0,0 +1,142 @@
import threading
import time
from urllib.parse import quote
from urllib.request import getproxies
import httpx
from server.services.device import DeviceProfile
class HttpClient:
def __init__(self) -> None:
self._proxy = {
"mode": "off",
"scheme": "http",
"host": "",
"port": "",
"username": "",
"password": "",
}
self._client: httpx.Client | None = None
self._lock = threading.Lock()
def configure_proxy(self, proxy: dict) -> None:
data = proxy or {}
mode = data.get("mode") or "off"
if mode not in ("off", "system", "custom"):
raise RuntimeError("代理方式无效")
scheme = data.get("scheme") or "http"
if scheme not in ("http", "socks5"):
raise RuntimeError("代理协议无效")
host = (data.get("host") or "").strip()
port = str(data.get("port") or "").strip()
if mode == "custom":
if not host:
raise RuntimeError("请填写代理地址")
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise RuntimeError("请填写 1 到 65535 的端口")
with self._lock:
self._proxy = {
"mode": mode,
"scheme": scheme,
"host": host,
"port": port,
"username": (data.get("username") or "").strip(),
"password": data.get("password") or "",
}
self._close_locked()
def request_json(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None) -> dict:
data = self.request_data(method, url, token, device, body)
if not isinstance(data, dict):
raise RuntimeError("接口没有返回数据")
return data
def request_data(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None):
headers = device.headers()
if token:
headers["apasstk"] = token
response = self._request(method, url, headers=headers, json=body)
payload = response.json()
if payload.get("code") != 200:
raise RuntimeError(f"{method} {url} -> {payload.get('code')} {payload.get('msg')}")
if "data" not in payload:
raise RuntimeError("接口没有返回数据")
return payload.get("data")
def _request(self, method: str, url: str, headers: dict, json: dict | None) -> httpx.Response:
last_error = None
for attempt in range(6):
try:
return self._client_locked().request(method, url, headers=headers, json=json)
except (httpx.HTTPError, TimeoutError, OSError) as exc:
last_error = exc
time.sleep(min(10, 1.5 * (attempt + 1)))
raise RuntimeError(f"请求失败: {last_error}")
def _client_locked(self) -> httpx.Client:
with self._lock:
if self._client is None or self._client.is_closed:
self._client = httpx.Client(**self._client_kwargs())
return self._client
def get_bytes(self, url: str) -> tuple[bytes, str]:
response = self._client_locked().get(url, headers={"User-Agent": "RaptDrama"})
response.raise_for_status()
media = response.headers.get("content-type", "image/jpeg").split(";", 1)[0].strip() or "image/jpeg"
if not media.startswith("image/"):
raise RuntimeError("不是图片")
return response.content, media
def _close_locked(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
def _client_kwargs(self) -> dict:
kwargs = {
"trust_env": False,
"follow_redirects": True,
"timeout": httpx.Timeout(25.0),
}
mode = self._proxy["mode"]
if mode == "off":
kwargs["proxy"] = None
return kwargs
if mode == "custom":
kwargs["proxy"] = self._proxy_url()
return kwargs
http_proxy, https_proxy = self._system_proxies()
if http_proxy and https_proxy and http_proxy != https_proxy:
kwargs["mounts"] = {
"http://": httpx.HTTPTransport(proxy=http_proxy),
"https://": httpx.HTTPTransport(proxy=https_proxy),
}
return kwargs
kwargs["proxy"] = https_proxy or http_proxy or None
return kwargs
def _proxy_url(self) -> str:
scheme = "socks5h" if self._proxy["scheme"] == "socks5" else "http"
auth = ""
username = self._proxy["username"]
if username:
password = quote(self._proxy["password"] or "", safe="")
auth = f"{quote(username, safe='')}:{password}@"
return f"{scheme}://{auth}{self._proxy['host']}:{self._proxy['port']}"
@staticmethod
def _system_proxies() -> tuple[str, str]:
found = getproxies()
return _with_scheme(found.get("http")), _with_scheme(found.get("https"))
def _with_scheme(value: str | None) -> str:
text = (value or "").strip()
if text and "://" not in text:
return "http://" + text
return text
http = HttpClient()
+57
View File
@@ -0,0 +1,57 @@
from server.services.adb import Adb
from server.services.device import DeviceProfile
from server.services.profile_poller import profile_poller
from server.services.rapt import rapt
from server.services.sessions import sessions
class LoginService:
def guest(self, raw_device: dict | None) -> dict:
device = DeviceProfile.merge(raw_device)
token = rapt.guest_token(device)
return self._finish(token, device)
def password(self, email: str, password: str, raw_device: dict | None) -> dict:
device = DeviceProfile.merge(raw_device)
token = rapt.email_token(email.strip(), password, device)
return self._finish(token, device)
def adb(self, serial: str, adb_path: str, source: str, raw_device: dict | None) -> dict:
if not serial.strip():
raise RuntimeError("请选择设备")
adb = Adb.locate(adb_path.strip())
if source == "device":
device = adb.read_profile(serial.strip())
chosen = DeviceProfile.merge(raw_device) if raw_device else None
if chosen is not None and raw_device:
if (raw_device.get("fingerprint") or "").strip():
device.fingerprint = chosen.fingerprint
if (raw_device.get("language") or "").strip():
device.language = chosen.language
else:
device = DeviceProfile.merge(raw_device)
token = adb.read_token(serial.strip())
return self._finish(token, device)
def current(self, token: str, raw_device: dict | None) -> dict:
token = token.strip()
if not token:
raise RuntimeError("请先登录")
device = sessions.bind(token, DeviceProfile.merge(raw_device))
profile = rapt.user_info(token, device)
self._watch(token, profile)
return {"token": token, "profile": profile, "device": device.as_dict()}
def _finish(self, token: str, device: DeviceProfile) -> dict:
bound = sessions.bind(token, device, replace=True)
profile = rapt.user_info(token, bound)
self._watch(token, profile)
return {"token": token, "profile": profile, "device": bound.as_dict()}
@staticmethod
def _watch(token: str, profile: dict) -> None:
sessions.remember(token, profile)
profile_poller.ensure(token)
login = LoginService()
+61
View File
@@ -0,0 +1,61 @@
import logging
import threading
from server.services.rapt import rapt
from server.services.sessions import sessions
log = logging.getLogger(__name__)
class ProfilePoller:
def __init__(self, interval: float = 15) -> None:
self.interval = interval
self._lock = threading.Lock()
self._token = ""
self._stop: threading.Event | None = None
self._thread: threading.Thread | None = None
def ensure(self, token: str) -> None:
with self._lock:
if self._token == token and self._thread is not None and self._thread.is_alive():
return
if self._stop is not None:
self._stop.set()
stop = threading.Event()
thread = threading.Thread(
target=self._loop,
args=(token, stop),
name="profile-poller",
daemon=True,
)
self._token = token
self._stop = stop
self._thread = thread
thread.start()
def stop_if(self, token: str) -> None:
with self._lock:
if self._token != token:
return
if self._stop is not None:
self._stop.set()
self._token = ""
self._stop = None
self._thread = None
def _loop(self, token: str, stop: threading.Event) -> None:
while not stop.wait(self.interval):
if sessions.active_token() != token:
return
device = sessions.device_for(token)
if device is None:
return
try:
profile = rapt.user_info(token, device)
except Exception:
log.warning("刷新个人信息失败", exc_info=True)
continue
sessions.set_profile(token, profile)
profile_poller = ProfilePoller()
+35
View File
@@ -0,0 +1,35 @@
from server.services.device import DeviceProfile
from server.services.http import http
API_BASE = "https://apis.raptdrama.com"
class RaptApi:
def guest_token(self, device: DeviceProfile) -> str:
data = self._post("/app/open/autologin", "", device, {})
token = data.get("token") or ""
if not token:
raise RuntimeError("游客登录没有返回 token")
return token
def email_token(self, email: str, password: str, device: DeviceProfile) -> str:
data = self._post("/app/open/emailLogin", "", device, {
"email": email,
"password": password,
})
token = data.get("token") or ""
if not token:
raise RuntimeError("邮箱登录没有返回 token")
return token
def user_info(self, token: str, device: DeviceProfile) -> dict:
data = self._post("/app/user/getUserInfo", token, device, {})
data.pop("token", None)
data.pop("accessToken", None)
return data
def _post(self, path: str, token: str, device: DeviceProfile, body: dict) -> dict:
return http.request_json("POST", API_BASE + path, token, device, body)
rapt = RaptApi()
+56
View File
@@ -0,0 +1,56 @@
import threading
from server.services.device import DeviceProfile
class SessionStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self._devices: dict[str, DeviceProfile] = {}
self._active = ""
self._profile: dict | None = None
self._revision = 0
def bind(self, token: str, device: DeviceProfile, replace: bool = False) -> DeviceProfile:
with self._lock:
if replace or token not in self._devices:
self._devices[token] = device
return self._devices[token]
def device_for(self, token: str) -> DeviceProfile | None:
with self._lock:
return self._devices.get(token)
def remember(self, token: str, profile: dict) -> None:
with self._lock:
self._active = token
self._profile = dict(profile)
self._revision += 1
def set_profile(self, token: str, profile: dict) -> bool:
with self._lock:
if token != self._active:
return False
self._profile = dict(profile)
self._revision += 1
return True
def active_token(self) -> str:
with self._lock:
return self._active
def snapshot(self) -> dict | None:
with self._lock:
if not self._active or self._profile is None:
return None
return {"profile": dict(self._profile), "revision": self._revision}
def clear(self, token: str) -> None:
with self._lock:
self._devices.pop(token, None)
if self._active == token:
self._active = ""
self._profile = None
sessions = SessionStore()
+2
View File
@@ -0,0 +1,2 @@
[build]
target-dir = "../dist/target"
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5015
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "raptdrama"
version = "0.1.0"
description = "RaptDrama desktop shell"
authors = ["nixevol"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "raptdrama_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>RaptDrama</title>
<style>
html, body { height: 100%; margin: 0; background: #101014; color: #f2f2f2; font: 16px/1.5 "Segoe UI", sans-serif; }
body { display: flex; align-items: center; justify-content: center; }
main { text-align: center; }
h1 { margin: 0 0 12px; font-size: 22px; font-weight: 600; }
p { margin: 0; color: #c8c8c8; }
</style>
</head>
<body>
<main>
<h1>RaptDrama</h1>
<p id="status">后台程序正在启动</p>
</main>
<script>
window.setStatus = (text) => {
document.getElementById("status").textContent = text;
};
</script>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager, Url};
struct Backend {
child: Mutex<Option<Child>>,
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.canonicalize()
.expect("项目根目录")
}
fn python_command(root: &Path) -> PathBuf {
let bundled = root.join("server").join(".venv").join("Scripts").join("python.exe");
if bundled.is_file() {
bundled
} else {
PathBuf::from("python")
}
}
fn free_port() -> Result<u16, String> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| format!("没有可用端口:{err}"))?;
listener
.local_addr()
.map(|addr| addr.port())
.map_err(|err| format!("没有可用端口:{err}"))
}
fn spawn_backend(root: &Path, port: u16) -> Result<Child, String> {
let python = python_command(root);
let static_dir = root.join("dist").join("ui");
if !static_dir.join("index.html").is_file() {
return Err("页面还没有编译完成".into());
}
let mut command = Command::new(&python);
command
.current_dir(root)
.arg("-m")
.arg("uvicorn")
.arg("server.app:app")
.arg("--host")
.arg("127.0.0.1")
.arg("--port")
.arg(port.to_string())
.env("RAPTD_STATIC", &static_dir)
.env("PYTHONUNBUFFERED", "1");
#[cfg(all(windows, not(debug_assertions)))]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
command.spawn().map_err(|err| format!("无法启动后台:{err}"))
}
fn health_ok(port: u16) -> bool {
let mut stream = match TcpStream::connect(("127.0.0.1", port)) {
Ok(stream) => stream,
Err(_) => return false,
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
if stream
.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.is_err()
{
return false;
}
let mut buf = Vec::new();
let mut tmp = [0_u8; 1024];
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(size) => buf.extend_from_slice(&tmp[..size]),
Err(err)
if err.kind() == std::io::ErrorKind::TimedOut
|| err.kind() == std::io::ErrorKind::WouldBlock =>
{
break;
}
Err(_) => return false,
}
}
let text = String::from_utf8_lossy(&buf);
text.contains("200") && text.to_ascii_lowercase().contains("\"ok\"")
}
fn child_exited(handle: &AppHandle) -> Option<String> {
let state = handle.try_state::<Backend>()?;
let mut guard = state.child.lock().ok()?;
let child = guard.as_mut()?;
match child.try_wait() {
Ok(Some(status)) => Some(format!("后台退出了:{status}")),
_ => None,
}
}
fn stop_child(handle: &AppHandle) {
let Some(state) = handle.try_state::<Backend>() else {
return;
};
let Ok(mut guard) = state.child.lock() else {
return;
};
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
fn boot(handle: &AppHandle, window: &tauri::WebviewWindow) -> Result<(), String> {
let root = repo_root();
let mut last_error = "后台启动超时".to_string();
for _ in 0..3 {
let port = free_port()?;
let child = spawn_backend(&root, port)?;
if let Some(state) = handle.try_state::<Backend>() {
let mut guard = state.child.lock().map_err(|_| "后台状态不可用".to_string())?;
*guard = Some(child);
}
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if health_ok(port) {
let url = Url::parse(&format!("http://127.0.0.1:{port}/")).map_err(|err| err.to_string())?;
eprintln!("RaptDrama http://127.0.0.1:{port}/");
let _ = std::io::Write::flush(&mut std::io::stderr());
window.navigate(url).map_err(|err| err.to_string())?;
return Ok(());
}
if let Some(message) = child_exited(handle) {
last_error = message;
break;
}
std::thread::sleep(Duration::from_millis(200));
}
stop_child(handle);
}
Err(last_error)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
app.manage(Backend {
child: Mutex::new(None),
});
let handle = app.handle().clone();
let window = handle
.get_webview_window("main")
.expect("主窗口");
std::thread::spawn(move || {
if let Err(message) = boot(&handle, &window) {
eprintln!("后台启动失败:{message}");
let quoted = serde_json::to_string(&message).unwrap_or_else(|_| "\"启动失败\"".to_string());
let _ = window.eval(&format!("window.setStatus({quoted})"));
}
});
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
if matches!(event, tauri::RunEvent::Exit) {
stop_child(app);
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
raptdrama_lib::run()
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "RaptDrama",
"version": "0.1.0",
"identifier": "com.raptdrama.desktop",
"build": {
"beforeDevCommand": {
"script": "npm run build --prefix ui",
"cwd": "..",
"wait": true
},
"beforeBuildCommand": {
"script": "npm run build --prefix ui",
"cwd": ".."
},
"frontendDist": "splash"
},
"app": {
"windows": [
{
"label": "main",
"title": "RaptDrama",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+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/**"],
},
},
}));