feat: 增加桌面端登录、视频清单和封面缓存
@@ -6,3 +6,10 @@ __pycache__/
|
||||
raptdrama_passion_full.json
|
||||
raptdrama_catalog.json
|
||||
video/
|
||||
cache/
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/schemas
|
||||
vendor/webview2/
|
||||
server/.venv/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "raptdrama",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.32
|
||||
httpx[socks]>=0.28
|
||||
@@ -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()})
|
||||
@@ -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),
|
||||
))
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
target-dir = "../dist/target"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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 |
@@ -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')
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||