Merge branch 'feature/ui-redesign' into 3.2-dev

# Conflicts:
#	backend/requirements.txt
This commit is contained in:
EstrellaXD
2026-01-23 17:54:27 +01:00
79 changed files with 5558 additions and 966 deletions

View File

@@ -1,15 +1,64 @@
[project]
name = "auto-bangumi"
version = "3.1.0"
description = "AutoBangumi - Automated anime download manager"
requires-python = ">=3.10"
dependencies = [
"anyio>=4.0.0",
"beautifulsoup4>=4.12.0",
"certifi>=2023.5.7",
"charset-normalizer>=3.1.0",
"click>=8.1.3",
"fastapi>=0.109.0",
"h11>=0.14.0",
"idna>=3.4",
"pydantic>=2.0.0",
"sniffio>=1.3.0",
"soupsieve>=2.4.1",
"typing_extensions>=4.0.0",
"urllib3>=2.0.3",
"uvicorn>=0.27.0",
"Jinja2>=3.1.2",
"python-dotenv>=1.0.0",
"python-jose>=3.3.0",
"passlib>=1.7.4",
"bcrypt>=4.0.1,<4.1",
"python-multipart>=0.0.6",
"sqlmodel>=0.0.14",
"sse-starlette>=1.6.5",
"semver>=3.0.1",
"openai>=1.54.3",
"httpx>=0.25.0",
"httpx-socks>=0.9.0",
"aiosqlite>=0.19.0",
"sqlalchemy[asyncio]>=2.0.0",
"webauthn>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
"black>=24.0.0",
]
[tool.pytest.ini_options]
testpaths = ["src/test"]
asyncio_mode = "auto"
[tool.ruff]
select = [
# pycodestyle(E): https://beta.ruff.rs/docs/rules/#pycodestyle-e-w
"E",
"E",
# Pyflakes(F): https://beta.ruff.rs/docs/rules/#pyflakes-f
"F",
"F",
# isort(I): https://beta.ruff.rs/docs/rules/#isort-i
"I"
]
ignore = [
# E501: https://beta.ruff.rs/docs/rules/line-too-long/
'E501',
'E501',
# F401: https://beta.ruff.rs/docs/rules/unused-import/
# avoid unused imports lint in `__init__.py`
'F401',

View File

@@ -1,29 +1,30 @@
anyio==3.7.0
anyio>=4.0.0
bs4==0.0.1
certifi==2023.5.7
charset-normalizer==3.1.0
click==8.1.3
fastapi==0.97.0
h11==0.14.0
idna==3.4
pydantic~=1.10
PySocks==1.7.1
qbittorrent-api==2023.9.53
requests==2.31.0
six==1.16.0
sniffio==1.3.0
soupsieve==2.4.1
typing_extensions
urllib3==2.0.3
uvicorn==0.22.0
attrdict==2.0.1
Jinja2==3.1.2
python-dotenv==1.0.0
python-jose==3.3.0
passlib==1.7.4
bcrypt==4.0.1
python-multipart==0.0.6
sqlmodel==0.0.8
sse-starlette==1.6.5
semver==3.0.1
openai==1.54.3
certifi>=2023.5.7
charset-normalizer>=3.1.0
click>=8.1.3
fastapi>=0.109.0
h11>=0.14.0
idna>=3.4
pydantic>=2.0.0
six>=1.16.0
sniffio>=1.3.0
soupsieve>=2.4.1
typing_extensions>=4.0.0
urllib3>=2.0.3
uvicorn>=0.27.0
Jinja2>=3.1.2
python-dotenv>=1.0.0
python-jose>=3.3.0
passlib>=1.7.4
bcrypt>=4.0.1
python-multipart>=0.0.6
sqlmodel>=0.0.14
sse-starlette>=1.6.5
semver>=3.0.1
openai>=1.54.3
httpx>=0.25.0
httpx-socks>=0.9.0
aiosqlite>=0.19.0
sqlalchemy[asyncio]>=2.0.0
webauthn>=2.0.0

47
backend/src/dev_server.py Normal file
View File

@@ -0,0 +1,47 @@
"""Minimal dev server that skips downloader check for UI testing."""
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi import APIRouter
from module.database.combine import Database
from module.database.engine import engine
# Initialize DB + migrations + default user
with Database(engine) as db:
db.create_table()
db.user.add_default_user()
# Build v1 router without program router (which blocks on downloader check)
from module.api.auth import router as auth_router
from module.api.bangumi import router as bangumi_router
from module.api.config import router as config_router
from module.api.log import router as log_router
from module.api.rss import router as rss_router
from module.api.search import router as search_router
v1 = APIRouter(prefix="/v1")
v1.include_router(auth_router)
v1.include_router(bangumi_router)
v1.include_router(config_router)
v1.include_router(log_router)
v1.include_router(rss_router)
v1.include_router(search_router)
# Stub status endpoint (real one lives in program router which blocks on downloader)
@v1.get("/status")
async def stub_status():
return {"status": True, "version": "dev"}
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(v1, prefix="/api")
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=7892)

View File

@@ -4,6 +4,7 @@ from .auth import router as auth_router
from .bangumi import router as bangumi_router
from .config import router as config_router
from .log import router as log_router
from .passkey import router as passkey_router
from .program import router as program_router
from .rss import router as rss_router
from .search import router as search_router
@@ -13,6 +14,7 @@ __all__ = "v1"
# API 1.0
v1 = APIRouter(prefix="/v1")
v1.include_router(auth_router)
v1.include_router(passkey_router)
v1.include_router(log_router)
v1.include_router(program_router)
v1.include_router(bangumi_router)

View File

@@ -127,6 +127,17 @@ async def refresh_poster(bangumi_id: int):
return u_response(resp)
@router.get(
path="/refresh/calendar",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_calendar():
with TorrentManager() as manager:
resp = manager.refresh_calendar()
return u_response(resp)
@router.get(
"/reset/all", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)

View File

@@ -0,0 +1,281 @@
"""
Passkey 管理 API
用于注册、列表、删除 Passkey 凭证
"""
import logging
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from sqlmodel import select
from module.database.engine import async_session_factory
from module.database.passkey import PasskeyDatabase
from module.models import APIResponse
from module.models.passkey import (
PasskeyAuthFinish,
PasskeyAuthStart,
PasskeyCreate,
PasskeyDelete,
PasskeyList,
)
from module.models.user import User
from module.security.api import active_user, get_current_user
from module.security.auth_strategy import PasskeyAuthStrategy
from module.security.jwt import create_access_token
from module.security.webauthn import get_webauthn_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/passkey", tags=["passkey"])
def _get_webauthn_from_request(request: Request):
"""
从请求中构造 WebAuthnService
优先使用浏览器的 Origin header与 clientDataJSON 中的 origin 一致)
"""
from urllib.parse import urlparse
origin = request.headers.get("origin")
if not origin:
# Fallback: 从 Referer 或 Host 推断
referer = request.headers.get("referer", "")
if referer:
parsed = urlparse(referer)
origin = f"{parsed.scheme}://{parsed.netloc}"
else:
host = request.headers.get("host", "localhost:7892")
forwarded_proto = request.headers.get("x-forwarded-proto")
scheme = forwarded_proto if forwarded_proto else request.url.scheme
origin = f"{scheme}://{host}"
parsed_origin = urlparse(origin)
rp_id = parsed_origin.hostname or "localhost"
return get_webauthn_service(rp_id, "AutoBangumi", origin)
# ============ 注册流程 ============
@router.post("/register/options", response_model=dict)
async def get_registration_options(
request: Request,
username: str = Depends(get_current_user),
):
"""
生成 Passkey 注册选项
前端调用 navigator.credentials.create() 时使用
"""
webauthn = _get_webauthn_from_request(request)
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get existing passkeys
passkey_db = PasskeyDatabase(session)
existing_passkeys = await passkey_db.get_passkeys_by_user_id(user.id)
options = webauthn.generate_registration_options(
username=username,
user_id=user.id,
existing_passkeys=existing_passkeys,
)
return options
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate registration options: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/register/verify", response_model=APIResponse)
async def verify_registration(
passkey_data: PasskeyCreate,
request: Request,
username: str = Depends(get_current_user),
):
"""
验证 Passkey 注册响应并保存
"""
webauthn = _get_webauthn_from_request(request)
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 验证 WebAuthn 响应
passkey = webauthn.verify_registration(
username=username,
credential=passkey_data.attestation_response,
device_name=passkey_data.name,
)
# 设置 user_id 并保存
passkey.user_id = user.id
passkey_db = PasskeyDatabase(session)
await passkey_db.create_passkey(passkey)
return JSONResponse(
status_code=200,
content={
"msg_en": f"Passkey '{passkey_data.name}' registered successfully",
"msg_zh": f"Passkey '{passkey_data.name}' 注册成功",
},
)
except ValueError as e:
logger.warning(f"Registration verification failed for {username}: {e}")
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to register passkey: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============ 认证流程 ============
@router.post("/auth/options", response_model=dict)
async def get_passkey_login_options(
auth_data: PasskeyAuthStart,
request: Request,
):
"""
生成 Passkey 登录选项challenge
前端先调用此接口,再调用 navigator.credentials.get()
"""
webauthn = _get_webauthn_from_request(request)
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == auth_data.username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
passkey_db = PasskeyDatabase(session)
passkeys = await passkey_db.get_passkeys_by_user_id(user.id)
if not passkeys:
raise HTTPException(
status_code=400, detail="No passkeys registered for this user"
)
options = webauthn.generate_authentication_options(
auth_data.username, passkeys
)
return options
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate login options: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/auth/verify", response_model=dict)
async def login_with_passkey(
auth_data: PasskeyAuthFinish,
response: Response,
request: Request,
):
"""
使用 Passkey 登录(替代密码登录)
"""
webauthn = _get_webauthn_from_request(request)
strategy = PasskeyAuthStrategy(webauthn)
resp = await strategy.authenticate(auth_data.username, auth_data.credential)
if resp.status:
token = create_access_token(
data={"sub": auth_data.username}, expires_delta=timedelta(days=1)
)
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
if auth_data.username not in active_user:
active_user.append(auth_data.username)
return {"access_token": token, "token_type": "bearer"}
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
# ============ Passkey 管理 ============
@router.get("/list", response_model=list[PasskeyList])
async def list_passkeys(username: str = Depends(get_current_user)):
"""获取用户的所有 Passkey"""
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
passkey_db = PasskeyDatabase(session)
passkeys = await passkey_db.get_passkeys_by_user_id(user.id)
return [passkey_db.to_list_model(pk) for pk in passkeys]
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to list passkeys: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/delete", response_model=APIResponse)
async def delete_passkey(
delete_data: PasskeyDelete,
username: str = Depends(get_current_user),
):
"""删除 Passkey"""
async with async_session_factory() as session:
try:
# Get user
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
passkey_db = PasskeyDatabase(session)
await passkey_db.delete_passkey(delete_data.passkey_id, user.id)
return JSONResponse(
status_code=200,
content={
"msg_en": "Passkey deleted successfully",
"msg_zh": "Passkey 删除成功",
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete passkey: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -24,7 +24,7 @@ async def startup():
@router.on_event("shutdown")
async def shutdown():
program.stop()
await program.stop()
@router.get(
@@ -69,7 +69,8 @@ async def start():
"/stop", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def stop():
return u_response(program.stop())
resp = await program.stop()
return u_response(resp)
@router.get("/status", response_model=dict, dependencies=[Depends(get_current_user)])
@@ -92,7 +93,7 @@ async def program_status():
"/shutdown", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def shutdown_program():
program.stop()
await program.stop()
logger.info("Shutting down program...")
os.kill(os.getpid(), signal.SIGINT)
return JSONResponse(
@@ -112,4 +113,4 @@ async def shutdown_program():
dependencies=[Depends(get_current_user)],
)
async def check_downloader_status():
return program.check_downloader()
return await program.check_downloader()

View File

@@ -1,10 +1,9 @@
import logging
from pathlib import Path
import requests
import httpx
from module.conf import VERSION, settings
from module.downloader import DownloadClient
from module.models import Config
from module.update import version_check
@@ -49,27 +48,28 @@ class Checker:
return True
@staticmethod
def check_downloader() -> bool:
async def check_downloader() -> bool:
from module.downloader import DownloadClient
try:
url = (
f"http://{settings.downloader.host}"
if "://" not in settings.downloader.host
else f"{settings.downloader.host}"
)
response = requests.get(url, timeout=2)
# if settings.downloader.type in response.text.lower():
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(url)
if "qbittorrent" in response.text.lower() or "vuetorrent" in response.text.lower():
with DownloadClient() as client:
if client.authed:
async with DownloadClient() as dl_client:
if dl_client.authed:
return True
else:
return False
else:
return False
except requests.exceptions.ReadTimeout:
except httpx.TimeoutException:
logger.error("[Checker] Downloader connect timeout.")
return False
except requests.exceptions.ConnectionError:
except httpx.ConnectError:
logger.error("[Checker] Downloader connect failed.")
return False
except Exception as e:

View File

@@ -16,14 +16,14 @@ from .sub_thread import RenameThread, RSSThread
logger = logging.getLogger(__name__)
figlet = r"""
_ ____ _
/\ | | | _ \ (_)
/ \ _ _| |_ ___ | |_) | __ _ _ __ __ _ _ _ _ __ ___ _
/ /\ \| | | | __/ _ \| _ < / _` | '_ \ / _` | | | | '_ ` _ \| |
/ ____ \ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |
/_/ \_\__,_|\__\___/|____/ \__,_|_| |_|\__, |\__,_|_| |_| |_|_|
__/ |
|___/
_ ____ _
/\ | | | _ \ (_)
/ \ _ _| |_ ___ | |_) | __ _ _ __ __ _ _ _ _ __ ___ _
/ /\ \| | | | __/ _ \| _ < / _` | '_ \ / _` | | | | '_ ` _ \| |
/ ____ \ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |
/_/ \_\__,_|\__\___/|____/ \__,_|_| |_|\__, |\__,_|_| |_| |_|_|
__/ |
|___/
"""
@@ -61,7 +61,7 @@ class Program(RenameThread, RSSThread):
async def start(self):
self.stop_event.clear()
settings.load()
while not self.downloader_status:
while not await self.check_downloader_status():
logger.warning("Downloader is not running.")
logger.info("Waiting for downloader to start.")
await asyncio.sleep(30)
@@ -77,11 +77,11 @@ class Program(RenameThread, RSSThread):
msg_zh="程序启动成功。",
)
def stop(self):
async def stop(self):
if self.is_running:
self.stop_event.set()
self.rename_stop()
self.rss_stop()
await self.rename_stop()
await self.rss_stop()
return ResponseModel(
status=True,
status_code=200,
@@ -97,7 +97,7 @@ class Program(RenameThread, RSSThread):
)
async def restart(self):
self.stop()
await self.stop()
await self.start()
return ResponseModel(
status=True,

View File

@@ -1,5 +1,4 @@
import asyncio
import threading
from module.checker import Checker
from module.conf import LEGACY_DATA_PATH
@@ -8,8 +7,8 @@ from module.conf import LEGACY_DATA_PATH
class ProgramStatus(Checker):
def __init__(self):
super().__init__()
self.stop_event = threading.Event()
self.lock = threading.Lock()
self.stop_event = asyncio.Event()
self.lock = asyncio.Lock()
self._downloader_status = False
self._torrents_status = False
self.event = asyncio.Event()
@@ -27,8 +26,11 @@ class ProgramStatus(Checker):
@property
def downloader_status(self):
return self._downloader_status
async def check_downloader_status(self) -> bool:
if not self._downloader_status:
self._downloader_status = self.check_downloader()
self._downloader_status = await self.check_downloader()
return self._downloader_status
@property

View File

@@ -1,6 +1,10 @@
import logging
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel
from module.models import Bangumi, User
from module.models.passkey import Passkey
from .bangumi import BangumiDatabase
from .engine import engine as e
@@ -8,6 +12,8 @@ from .rss import RSSDatabase
from .torrent import TorrentDatabase
from .user import UserDatabase
logger = logging.getLogger(__name__)
class Database(Session):
def __init__(self, engine=e):
@@ -20,6 +26,20 @@ class Database(Session):
def create_table(self):
SQLModel.metadata.create_all(self.engine)
self._migrate_columns()
def _migrate_columns(self):
"""Add new columns to existing tables if they don't exist."""
inspector = inspect(self.engine)
if "bangumi" in inspector.get_table_names():
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "air_weekday" not in columns:
with self.engine.connect() as conn:
conn.execute(
text("ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER")
)
conn.commit()
logger.info("[Database] Migrated: added air_weekday column to bangumi table.")
def drop_table(self):
SQLModel.metadata.drop_all(self.engine)

View File

@@ -1,7 +1,13 @@
from sqlmodel import Session, create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import create_engine
from module.conf import DATA_PATH
# Sync engine (used by Database which extends Session)
engine = create_engine(DATA_PATH)
db_session = Session(engine)
# Async engine (for passkey operations)
ASYNC_DATA_PATH = DATA_PATH.replace("sqlite:///", "sqlite+aiosqlite:///")
async_engine = create_async_engine(ASYNC_DATA_PATH)
async_session_factory = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)

View File

@@ -0,0 +1,78 @@
"""
Passkey 数据库操作层
"""
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from module.models.passkey import Passkey, PasskeyList
logger = logging.getLogger(__name__)
class PasskeyDatabase:
def __init__(self, session: AsyncSession):
self.session = session
async def create_passkey(self, passkey: Passkey) -> Passkey:
"""创建新的 Passkey 凭证"""
self.session.add(passkey)
await self.session.commit()
await self.session.refresh(passkey)
logger.info(f"Created passkey '{passkey.name}' for user_id={passkey.user_id}")
return passkey
async def get_passkey_by_credential_id(
self, credential_id: str
) -> Optional[Passkey]:
"""通过 credential_id 查找 Passkey用于认证"""
statement = select(Passkey).where(Passkey.credential_id == credential_id)
result = await self.session.execute(statement)
return result.scalar_one_or_none()
async def get_passkeys_by_user_id(self, user_id: int) -> List[Passkey]:
"""获取用户的所有 Passkey"""
statement = select(Passkey).where(Passkey.user_id == user_id)
result = await self.session.execute(statement)
return list(result.scalars().all())
async def get_passkey_by_id(self, passkey_id: int, user_id: int) -> Passkey:
"""获取特定 Passkey带权限检查"""
statement = select(Passkey).where(
Passkey.id == passkey_id, Passkey.user_id == user_id
)
result = await self.session.execute(statement)
passkey = result.scalar_one_or_none()
if not passkey:
raise HTTPException(status_code=404, detail="Passkey not found")
return passkey
async def update_passkey_usage(self, passkey: Passkey, new_sign_count: int):
"""更新 Passkey 使用记录(签名计数器 + 最后使用时间)"""
passkey.sign_count = new_sign_count
passkey.last_used_at = datetime.utcnow()
self.session.add(passkey)
await self.session.commit()
async def delete_passkey(self, passkey_id: int, user_id: int) -> bool:
"""删除 Passkey"""
passkey = await self.get_passkey_by_id(passkey_id, user_id)
await self.session.delete(passkey)
await self.session.commit()
logger.info(f"Deleted passkey id={passkey_id} for user_id={user_id}")
return True
def to_list_model(self, passkey: Passkey) -> PasskeyList:
"""转换为安全的列表展示模型"""
return PasskeyList(
id=passkey.id,
name=passkey.name,
created_at=passkey.created_at,
last_used_at=passkey.last_used_at,
backup_eligible=passkey.backup_eligible,
aaguid=passkey.aaguid,
)

View File

@@ -4,6 +4,7 @@ from module.database import Database
from module.downloader import DownloadClient
from module.models import Bangumi, BangumiUpdate, ResponseModel
from module.parser import TitleParser
from module.parser.analyser.bgm_calendar import fetch_bgm_calendar, match_weekday
logger = logging.getLogger(__name__)
@@ -154,6 +155,37 @@ class TorrentManager(Database):
msg_zh="刷新海报链接成功。",
)
def refresh_calendar(self):
"""Fetch Bangumi.tv calendar and update air_weekday for all bangumi."""
calendar_items = fetch_bgm_calendar()
if not calendar_items:
return ResponseModel(
status_code=500,
status=False,
msg_en="Failed to fetch calendar data from Bangumi.tv.",
msg_zh="从 Bangumi.tv 获取放送表失败。",
)
bangumis = self.bangumi.search_all()
updated = 0
for bangumi in bangumis:
if bangumi.deleted:
continue
weekday = match_weekday(
bangumi.official_title, bangumi.title_raw, calendar_items
)
if weekday is not None and weekday != bangumi.air_weekday:
bangumi.air_weekday = weekday
updated += 1
if updated > 0:
self.bangumi.update_all(bangumis)
logger.info(f"[Manager] Calendar refresh: updated {updated} bangumi.")
return ResponseModel(
status_code=200,
status=True,
msg_en=f"Calendar refreshed. Updated {updated} anime.",
msg_zh=f"放送表已刷新,更新了 {updated} 部番剧。",
)
def search_all_bangumi(self):
datas = self.bangumi.search_all()
if not datas:

View File

@@ -1,5 +1,6 @@
from .bangumi import Bangumi, BangumiUpdate, Episode, Notification
from .config import Config
from .passkey import Passkey, PasskeyCreate, PasskeyDelete, PasskeyList
from .response import APIResponse, ResponseModel
from .rss import RSSItem, RSSUpdate
from .torrent import EpisodeFile, SubtitleFile, Torrent, TorrentUpdate

View File

@@ -27,6 +27,7 @@ class Bangumi(SQLModel, table=True):
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
deleted: bool = Field(False, alias="deleted", title="是否已删除")
air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期")
class BangumiUpdate(SQLModel):
@@ -50,6 +51,7 @@ class BangumiUpdate(SQLModel):
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
deleted: bool = Field(False, alias="deleted", title="是否已删除")
air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期")
class Notification(BaseModel):

View File

@@ -0,0 +1,75 @@
"""
WebAuthn Passkey 数据模型
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from sqlmodel import Field, SQLModel
class Passkey(SQLModel, table=True):
"""存储 WebAuthn 凭证的数据库模型"""
__tablename__ = "passkey"
id: int = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
# 用户友好的名称 (e.g., "iPhone 15", "MacBook Pro")
name: str = Field(min_length=1, max_length=64)
# WebAuthn 核心字段
credential_id: str = Field(unique=True, index=True) # Base64URL encoded
public_key: str # CBOR encoded public key, Base64 stored
sign_count: int = Field(default=0) # 防止克隆攻击
# 可选的设备信息
aaguid: Optional[str] = None # Authenticator AAGUID
transports: Optional[str] = None # JSON array: ["usb", "nfc", "ble", "internal"]
# 审计字段
created_at: datetime = Field(default_factory=datetime.utcnow)
last_used_at: Optional[datetime] = None
# 备份状态 (是否为多设备凭证,如 iCloud Keychain)
backup_eligible: bool = Field(default=False)
backup_state: bool = Field(default=False)
class PasskeyCreate(BaseModel):
"""创建 Passkey 的请求模型"""
name: str = Field(min_length=1, max_length=64)
# 注册完成后的 WebAuthn 响应
attestation_response: dict
class PasskeyList(BaseModel):
"""返回给前端的 Passkey 列表(不含敏感数据)"""
id: int
name: str
created_at: datetime
last_used_at: Optional[datetime]
backup_eligible: bool
aaguid: Optional[str]
class PasskeyDelete(BaseModel):
"""删除 Passkey 请求"""
passkey_id: int
class PasskeyAuthStart(BaseModel):
"""Passkey 认证开始请求"""
username: str
class PasskeyAuthFinish(BaseModel):
"""Passkey 认证完成请求"""
username: str
credential: dict

View File

@@ -0,0 +1,88 @@
import logging
from module.network import RequestContent
logger = logging.getLogger(__name__)
BGM_CALENDAR_URL = "https://api.bgm.tv/calendar"
def fetch_bgm_calendar() -> list[dict]:
"""Fetch the current season's broadcast calendar from Bangumi.tv API.
Returns a flat list of anime items with their air_weekday (0=Mon, ..., 6=Sun).
"""
with RequestContent() as req:
data = req.get_json(BGM_CALENDAR_URL)
if not data:
logger.warning("[BGM Calendar] Failed to fetch calendar data.")
return []
items = []
for day_group in data:
weekday_info = day_group.get("weekday", {})
# Bangumi.tv uses 1=Mon, 2=Tue, ..., 7=Sun
# Convert to 0=Mon, 1=Tue, ..., 6=Sun
bgm_weekday = weekday_info.get("id")
if bgm_weekday is None:
continue
weekday = bgm_weekday - 1 # 1-7 → 0-6
for item in day_group.get("items", []):
items.append({
"name": item.get("name", ""), # Japanese title
"name_cn": item.get("name_cn", ""), # Chinese title
"air_weekday": weekday,
})
logger.info(f"[BGM Calendar] Fetched {len(items)} airing anime from Bangumi.tv.")
return items
def match_weekday(official_title: str, title_raw: str, calendar_items: list[dict]) -> int | None:
"""Match a bangumi against calendar items to find its air weekday.
Matching strategy:
1. Exact match on Chinese title (name_cn == official_title)
2. Exact match on Japanese title (name == title_raw or official_title)
3. Substring match (name_cn in official_title or vice versa)
4. Substring match on Japanese title
"""
official_title_clean = official_title.strip()
title_raw_clean = title_raw.strip()
for item in calendar_items:
name_cn = item["name_cn"].strip()
name = item["name"].strip()
if not name_cn and not name:
continue
# Exact match on Chinese title
if name_cn and name_cn == official_title_clean:
return item["air_weekday"]
# Exact match on Japanese/original title
if name and (name == title_raw_clean or name == official_title_clean):
return item["air_weekday"]
# Second pass: substring matching
for item in calendar_items:
name_cn = item["name_cn"].strip()
name = item["name"].strip()
if not name_cn and not name:
continue
# Chinese title substring (at least 4 chars to avoid false positives)
if name_cn and len(name_cn) >= 4:
if name_cn in official_title_clean or official_title_clean in name_cn:
return item["air_weekday"]
# Japanese title substring
if name and len(name) >= 4:
if name in title_raw_clean or title_raw_clean in name:
return item["air_weekday"]
return None

View File

@@ -0,0 +1,104 @@
"""
认证策略抽象层
将密码认证和 Passkey 认证统一为策略模式
"""
from abc import ABC, abstractmethod
from sqlmodel import select
from module.database.engine import async_session_factory
from module.database.passkey import PasskeyDatabase
from module.models import ResponseModel
from module.models.user import User
class AuthStrategy(ABC):
"""认证策略基类"""
@abstractmethod
async def authenticate(self, username: str, credential: dict) -> ResponseModel:
"""
执行认证
Args:
username: 用户名
credential: 认证凭证(密码或 WebAuthn 响应)
Returns:
ResponseModel with status and user info
"""
pass
class PasskeyAuthStrategy(AuthStrategy):
"""Passkey 认证策略"""
def __init__(self, webauthn_service):
self.webauthn_service = webauthn_service
async def authenticate(self, username: str, credential: dict) -> ResponseModel:
"""使用 WebAuthn Passkey 认证"""
async with async_session_factory() as session:
# 1. 查找用户
try:
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
except ValueError:
return ResponseModel(
status_code=401,
status=False,
msg_en="User not found",
msg_zh="用户不存在",
)
# 2. 提取 credential_id 并查找对应的 passkey
try:
raw_id = credential.get("rawId")
if not raw_id:
raise ValueError("Missing credential ID")
# 将 rawId 从 base64url 转换为标准格式
credential_id_str = self.webauthn_service.base64url_encode(
self.webauthn_service.base64url_decode(raw_id)
)
passkey_db = PasskeyDatabase(session)
passkey = await passkey_db.get_passkey_by_credential_id(credential_id_str)
if not passkey or passkey.user_id != user.id:
raise ValueError("Passkey not found or not owned by user")
except Exception:
return ResponseModel(
status_code=401,
status=False,
msg_en="Invalid passkey credential",
msg_zh="Passkey 凭证无效",
)
# 3. 验证 WebAuthn 签名
try:
new_sign_count = self.webauthn_service.verify_authentication(
username, credential, passkey
)
# 4. 更新使用记录
await passkey_db.update_passkey_usage(passkey, new_sign_count)
return ResponseModel(
status_code=200,
status=True,
msg_en="Login successfully with passkey",
msg_zh="通过 Passkey 登录成功",
)
except ValueError as e:
return ResponseModel(
status_code=401,
status=False,
msg_en=f"Passkey verification failed: {str(e)}",
msg_zh=f"Passkey 验证失败: {str(e)}",
)

View File

@@ -0,0 +1,277 @@
"""
WebAuthn 认证服务层
封装 py_webauthn 库的复杂性,提供清晰的注册和认证接口
"""
import base64
import json
import logging
from typing import List, Optional
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
AuthenticatorTransport,
CredentialDeviceType,
PublicKeyCredentialDescriptor,
PublicKeyCredentialType,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from module.models.passkey import Passkey
logger = logging.getLogger(__name__)
class WebAuthnService:
"""WebAuthn 核心业务逻辑"""
def __init__(self, rp_id: str, rp_name: str, origin: str):
"""
Args:
rp_id: 依赖方 ID (e.g., "localhost" or "autobangumi.example.com")
rp_name: 依赖方名称 (e.g., "AutoBangumi")
origin: 前端 origin (e.g., "http://localhost:5173")
"""
self.rp_id = rp_id
self.rp_name = rp_name
self.origin = origin
# 存储临时的 challenge生产环境应使用 Redis
self._challenges: dict[str, bytes] = {}
# ============ 注册流程 ============
def generate_registration_options(
self, username: str, user_id: int, existing_passkeys: List[Passkey]
) -> dict:
"""
生成 WebAuthn 注册选项
Args:
username: 用户名
user_id: 用户 ID转为 bytes
existing_passkeys: 用户已有的 Passkey用于排除
Returns:
JSON-serializable registration options
"""
# 将已有凭证转为排除列表
exclude_credentials = [
PublicKeyCredentialDescriptor(
id=self.base64url_decode(pk.credential_id),
type=PublicKeyCredentialType.PUBLIC_KEY,
transports=self._parse_transports(pk.transports),
)
for pk in existing_passkeys
]
options = generate_registration_options(
rp_id=self.rp_id,
rp_name=self.rp_name,
user_id=str(user_id).encode("utf-8"),
user_name=username,
user_display_name=username,
exclude_credentials=exclude_credentials if exclude_credentials else None,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256, # -7: ES256
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, # -257: RS256
],
)
# 存储 challenge 用于后续验证
challenge_key = f"reg_{username}"
self._challenges[challenge_key] = options.challenge
logger.debug(f"Generated registration challenge for {username}")
return json.loads(options_to_json(options))
def verify_registration(
self, username: str, credential: dict, device_name: str
) -> Passkey:
"""
验证注册响应并创建 Passkey 对象
Args:
username: 用户名
credential: 来自前端的 credential 响应
device_name: 用户输入的设备名称
Returns:
Passkey 对象(未保存到数据库)
Raises:
ValueError: 验证失败
"""
challenge_key = f"reg_{username}"
expected_challenge = self._challenges.get(challenge_key)
if not expected_challenge:
raise ValueError("Challenge not found or expired")
try:
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=self.rp_id,
expected_origin=self.origin,
)
# 构造 Passkey 对象
passkey = Passkey(
user_id=0, # 调用方设置
name=device_name,
credential_id=self.base64url_encode(verification.credential_id),
public_key=base64.b64encode(verification.credential_public_key).decode(
"utf-8"
),
sign_count=verification.sign_count,
aaguid=verification.aaguid if verification.aaguid else None,
backup_eligible=verification.credential_device_type
== CredentialDeviceType.MULTI_DEVICE,
backup_state=verification.credential_backed_up,
)
logger.info(
f"Successfully verified registration for {username}, device: {device_name}"
)
return passkey
except Exception as e:
logger.error(f"Registration verification failed: {e}")
raise ValueError(f"Invalid registration response: {str(e)}")
finally:
# 清理使用过的 challenge无论成功或失败都清理防止重放攻击
self._challenges.pop(challenge_key, None)
# ============ 认证流程 ============
def generate_authentication_options(
self, username: str, passkeys: List[Passkey]
) -> dict:
"""
生成 WebAuthn 认证选项
Args:
username: 用户名
passkeys: 用户的 Passkey 列表(限定可用凭证)
Returns:
JSON-serializable authentication options
"""
allow_credentials = [
PublicKeyCredentialDescriptor(
id=self.base64url_decode(pk.credential_id),
type=PublicKeyCredentialType.PUBLIC_KEY,
transports=self._parse_transports(pk.transports),
)
for pk in passkeys
]
options = generate_authentication_options(
rp_id=self.rp_id,
allow_credentials=allow_credentials if allow_credentials else None,
user_verification=UserVerificationRequirement.PREFERRED,
)
# 存储 challenge
challenge_key = f"auth_{username}"
self._challenges[challenge_key] = options.challenge
logger.debug(f"Generated authentication challenge for {username}")
return json.loads(options_to_json(options))
def verify_authentication(
self, username: str, credential: dict, passkey: Passkey
) -> int:
"""
验证认证响应
Args:
username: 用户名
credential: 来自前端的 credential 响应
passkey: 对应的 Passkey 对象
Returns:
新的 sign_count用于更新数据库
Raises:
ValueError: 验证失败
"""
challenge_key = f"auth_{username}"
expected_challenge = self._challenges.get(challenge_key)
if not expected_challenge:
raise ValueError("Challenge not found or expired")
try:
# 解码 public key
credential_public_key = base64.b64decode(passkey.public_key)
verification = verify_authentication_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=self.rp_id,
expected_origin=self.origin,
credential_public_key=credential_public_key,
credential_current_sign_count=passkey.sign_count,
)
logger.info(f"Successfully verified authentication for {username}")
return verification.new_sign_count
except Exception as e:
logger.error(f"Authentication verification failed: {e}")
raise ValueError(f"Invalid authentication response: {str(e)}")
finally:
# 清理 challenge无论成功或失败都清理防止重放攻击
self._challenges.pop(challenge_key, None)
# ============ 辅助方法 ============
def _parse_transports(
self, transports_json: Optional[str]
) -> List[AuthenticatorTransport]:
"""解析存储的 transports JSON"""
if not transports_json:
return []
try:
transport_strings = json.loads(transports_json)
return [AuthenticatorTransport(t) for t in transport_strings]
except Exception:
return []
def base64url_encode(self, data: bytes) -> str:
"""Base64URL 编码(无 padding"""
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
def base64url_decode(self, data: str) -> bytes:
"""Base64URL 解码(补齐 padding"""
padding = 4 - len(data) % 4
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
# 全局 WebAuthn 服务实例存储
_webauthn_services: dict[str, WebAuthnService] = {}
def get_webauthn_service(rp_id: str, rp_name: str, origin: str) -> WebAuthnService:
"""
获取或创建 WebAuthnService 实例
使用缓存以保持 challenge 状态
"""
key = f"{rp_id}:{origin}"
if key not in _webauthn_services:
_webauthn_services[key] = WebAuthnService(rp_id, rp_name, origin)
return _webauthn_services[key]

View File

@@ -0,0 +1,35 @@
"""
Minimal test server for passkey development.
Uses the real auth and passkey API routes without the downloader check.
Run with: uv run python test_passkey_server.py
"""
import uvicorn
from fastapi import FastAPI
from module.api.auth import router as auth_router
from module.api.passkey import router as passkey_router
from module.database import Database
from module.update.startup import first_run
app = FastAPI(title="AutoBangumi Passkey Test")
# Mount real routers
app.include_router(auth_router, prefix="/api/v1")
app.include_router(passkey_router, prefix="/api/v1")
@app.on_event("startup")
async def startup():
"""Create tables and default user (no downloader check)"""
with Database() as db:
db.create_table()
db.user.add_default_user()
@app.get("/")
def index():
return {"status": "Passkey test server running"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7892)

55
docs/changelog/3.2.md Normal file
View File

@@ -0,0 +1,55 @@
# [3.2] - 2025-01
## Backend
### Features
- 新增 WebAuthn Passkey 无密码登录支持
- 支持注册、认证、管理 Passkey 凭证
- 支持多设备凭证iCloud Keychain 等)备份检测
- 支持克隆攻击防护sign_count 验证)
- 认证策略模式,统一密码登录和 Passkey 登录接口
- 数据库层新增异步支持aiosqlite为 Passkey 操作提供非阻塞 I/O
- `UserDatabase` 支持同步/异步双模式,兼容新旧代码路径
- `Database` 上下文管理器同时支持 `with`(同步)和 `async with`(异步)
### Changes
- 升级 WebAuthn 依赖至 py_webauthn 2.7.0
- `_get_webauthn_from_request` 优先使用浏览器 Origin header修复跨端口开发环境下的验证问题
- `auth_user``update_user_info` 转为异步函数
### Bugfixes
- 修复 `aaguid` 类型错误py_webauthn 2.7.0 中为 `str`,不再是 `bytes`
- 修复 `credential_backup_eligible` 字段不存在的问题(改用 `credential_device_type`
- 修复 `verify_authentication_response` 传入无效参数 `credential_id` 导致 TypeError
- 修复程序启动错误
- 修复程序重启错误
- 修复 episode 解析支持 int 和 float 类型
- 修复 #805#855
- 修复多行标题解析后处理问题
- 修复全局 RSS 过滤器需要重启才能生效的问题
## Frontend
### Features
- 全新 UI 设计系统重构
- 统一的设计令牌(颜色、字体、间距、阴影、动画)
- 支持亮色/暗色主题切换
- 完善的无障碍访问支持ARIA、键盘导航、焦点管理
- 响应式布局适配移动端
- 新增 Passkey 管理面板(设置页)
- WebAuthn 浏览器支持检测
- 设备名称自动识别
- Passkey 列表展示与删除
- 登录页新增 Passkey 指纹登录按钮
- 新增可调比例图片组件
- 新增移动端搜索样式
- 优化移动端 Bangumi 列表样式
### Changes
- 重构搜索逻辑,移除 rxjs 依赖
- 升级前端依赖

View File

@@ -4,11 +4,24 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#FAFAFA" />
<link rel="icon" href="/images/logo.svg" />
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png" />
<meta name="description" content="Automated Bangumi Download Tool" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" />
<title>AutoBangumi</title>
<script>
// Apply dark mode before render to prevent flash
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<div id="app"></div>

View File

@@ -1,40 +1,88 @@
<script setup lang="ts">
import { type GlobalThemeOverrides, NConfigProvider } from 'naive-ui';
const theme: GlobalThemeOverrides = {
Spin: {
color: '#fff',
},
DataTable: {
thColor: 'rgba(255, 255, 255, 0)',
thColorHover: 'rgba(255, 255, 255, 0)',
tdColorHover: 'rgba(255, 255, 255, 0)',
},
Checkbox: {
colorChecked: '#4e3c94',
borderFocus: '#4e3c94',
boxShadowFocus: '0 0 0 2px rgba(78, 60, 148, 0.2)',
borderChecked: '1px solid #4e3c94',
},
};
import { computed } from 'vue';
import { type GlobalThemeOverrides, NConfigProvider, darkTheme } from 'naive-ui';
const { isDark } = useDarkMode();
const { refresh, isLoggedIn } = useAuth();
if (isLoggedIn.value) {
refresh();
}
const lightOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#6C4AB6',
primaryColorHover: '#563A92',
primaryColorPressed: '#4A3291',
bodyColor: '#FAFAFA',
cardColor: '#FFFFFF',
borderColor: '#E2E8F0',
textColorBase: '#1E293B',
textColor1: '#1E293B',
textColor2: '#64748B',
textColor3: '#94A3B8',
},
Spin: {
color: '#6C4AB6',
},
DataTable: {
thColor: 'transparent',
thColorHover: 'transparent',
tdColorHover: 'var(--color-surface-hover)',
borderColor: 'var(--color-border)',
},
Checkbox: {
colorChecked: '#6C4AB6',
borderFocus: '#6C4AB6',
boxShadowFocus: '0 0 0 2px rgba(108, 74, 182, 0.2)',
borderChecked: '1px solid #6C4AB6',
},
};
const darkOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#8B6CC7',
primaryColorHover: '#A78BDB',
primaryColorPressed: '#7B5CB7',
bodyColor: '#0F172A',
cardColor: '#1E293B',
borderColor: '#334155',
textColorBase: '#F1F5F9',
textColor1: '#F1F5F9',
textColor2: '#94A3B8',
textColor3: '#64748B',
},
Spin: {
color: '#8B6CC7',
},
DataTable: {
thColor: 'transparent',
thColorHover: 'transparent',
tdColorHover: 'var(--color-surface-hover)',
borderColor: 'var(--color-border)',
},
Checkbox: {
colorChecked: '#8B6CC7',
borderFocus: '#8B6CC7',
boxShadowFocus: '0 0 0 2px rgba(139, 108, 199, 0.2)',
borderChecked: '1px solid #8B6CC7',
},
};
const themeOverrides = computed(() => isDark.value ? darkOverrides : lightOverrides);
const naiveTheme = computed(() => isDark.value ? darkTheme : null);
</script>
<template>
<Suspense>
<NConfigProvider :theme-overrides="theme">
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<RouterView></RouterView>
</NConfigProvider>
</Suspense>
</template>
<style lang="scss">
@import './style/transition';
@import './style/var';
@import './style/transition';
@import './style/global';
</style>

View File

@@ -13,6 +13,7 @@ export const apiBangumi = {
...bangumi,
filter: bangumi.filter.split(','),
rss_link: bangumi.rss_link.split(','),
air_weekday: bangumi.air_weekday ?? null,
}));
return result;
},
@@ -30,6 +31,7 @@ export const apiBangumi = {
...data,
filter: data.filter.split(','),
rss_link: data.rss_link.split(','),
air_weekday: data.air_weekday ?? null,
};
return result;
},
@@ -134,4 +136,14 @@ export const apiBangumi = {
);
return data;
},
/**
* 从 Bangumi.tv 刷新放送日历数据
*/
async refreshCalendar() {
const { data } = await axios.get<ApiSuccess>(
'api/v1/bangumi/refresh/calendar'
);
return data;
},
};

88
webui/src/api/passkey.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { ApiSuccess } from '#/api';
import type { LoginSuccess } from '#/auth';
import type {
AuthenticationOptions,
PasskeyAuthFinishRequest,
PasskeyAuthStartRequest,
PasskeyCreateRequest,
PasskeyDeleteRequest,
PasskeyItem,
RegistrationOptions,
} from '#/passkey';
/**
* Passkey API 客户端
*/
export const apiPasskey = {
// ============ 注册流程 ============
/**
* 获取注册选项(步骤 1
*/
async getRegistrationOptions(): Promise<RegistrationOptions> {
const { data } = await axios.post<RegistrationOptions>(
'api/v1/passkey/register/options'
);
return data;
},
/**
* 提交注册结果(步骤 2
*/
async verifyRegistration(request: PasskeyCreateRequest): Promise<ApiSuccess> {
const { data } = await axios.post<ApiSuccess>(
'api/v1/passkey/register/verify',
request
);
return data;
},
// ============ 认证流程 ============
/**
* 获取登录选项(步骤 1
*/
async getLoginOptions(
request: PasskeyAuthStartRequest
): Promise<AuthenticationOptions> {
const { data } = await axios.post<AuthenticationOptions>(
'api/v1/passkey/auth/options',
request
);
return data;
},
/**
* 提交认证结果(步骤 2
*/
async loginWithPasskey(
request: PasskeyAuthFinishRequest
): Promise<LoginSuccess> {
const { data } = await axios.post<LoginSuccess>(
'api/v1/passkey/auth/verify',
request
);
return data;
},
// ============ 管理 ============
/**
* 获取 Passkey 列表
*/
async list(): Promise<PasskeyItem[]> {
const { data } = await axios.get<PasskeyItem[]>('api/v1/passkey/list');
return data;
},
/**
* 删除 Passkey
*/
async delete(request: PasskeyDeleteRequest): Promise<ApiSuccess> {
const { data } = await axios.post<ApiSuccess>(
'api/v1/passkey/delete',
request
);
return data;
},
};

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import { Write } from '@icon-park/vue-next';
import { ErrorPicture, Write } from '@icon-park/vue-next';
import type { BangumiRule } from '#/bangumi';
withDefaults(
defineProps<{
type?: 'primary' | 'search';
type?: 'primary' | 'search' | 'mobile';
bangumi: BangumiRule;
}>(),
{
@@ -16,81 +16,64 @@ defineEmits(['click']);
</script>
<template>
<template v-if="type === 'primary'">
<div w="full pc:150" is-btn @click="() => $emit('click')">
<div rounded-4 overflow-hidden poster-shandow rel>
<ab-image
:src="bangumi.poster_link"
:aspect-ratio="1 / 1.5"
w-full
></ab-image>
<div
abs
f-cer
z-1
inset-0
opacity-0
transition="all duration-300"
hover="backdrop-blur-2 bg-white bg-opacity-30 opacity-100"
active="duration-0 bg-opacity-60"
class="group"
>
<div
text-white
rounded="1/2"
wh-44
f-cer
bg-theme-row
group-active="poster-pen-active"
>
<Write size="20" />
</div>
<!-- Grid poster card -->
<div
v-if="type === 'primary'"
class="card"
role="button"
tabindex="0"
:aria-label="`Edit ${bangumi.official_title}`"
@click="() => $emit('click')"
@keydown.enter="() => $emit('click')"
>
<div class="card-poster">
<template v-if="bangumi.poster_link">
<img :src="bangumi.poster_link" :alt="bangumi.official_title" class="card-img" />
</template>
<template v-else>
<div class="card-placeholder">
<ErrorPicture theme="outline" size="24" />
</div>
</div>
</template>
<div py-4>
<div text-h3 truncate>{{ bangumi.official_title }}</div>
<div flex="~ wrap col" pc:flex-row gap-5>
<template v-for="i in ['season', 'group_name']" :key="i">
<ab-tag
v-if="bangumi[i]"
:title="i === 'season' ? `Season ${bangumi[i]}` : bangumi[i]"
type="primary"
pc:max-w="1/2"
/>
</template>
<div class="card-overlay">
<div class="card-edit-btn">
<Write size="18" />
</div>
</div>
</div>
</template>
<template v-else-if="type === 'search'">
<div
w-480
max-w-90vw
rounded-12
p-4
shadow
bg="#eee5f4"
transition="opacity ease-in-out duration-300"
>
<div w-full bg-white rounded-8 p-12 flex gap-x-14>
<div w-72 rounded-6 overflow-hidden>
<ab-image :src="bangumi.poster_link" w-full></ab-image>
<div class="card-info">
<div class="card-title">{{ bangumi.official_title }}</div>
<div class="card-tags">
<ab-tag :title="`Season ${bangumi.season}`" type="primary" />
<ab-tag
v-if="bangumi.group_name"
:title="bangumi.group_name"
type="primary"
/>
</div>
</div>
</div>
<!-- Search result card -->
<div v-else-if="type === 'search'" class="search-card">
<div class="search-card-inner">
<div class="search-card-content">
<div class="search-card-thumb">
<template v-if="bangumi.poster_link">
<img :src="bangumi.poster_link" :alt="bangumi.official_title" class="search-card-img" />
</template>
<template v-else>
<div class="card-placeholder card-placeholder--small">
<ErrorPicture theme="outline" size="20" />
</div>
</template>
</div>
<div flex="~ col 1 gap-y-4 justify-between">
<div text="h3 primary">
{{ bangumi.official_title }}
</div>
<div flex="~ wrap gap-8">
<template
v-for="i in ['season', 'group_name', 'subtitle']"
:key="i"
>
<div class="search-card-meta">
<div class="search-card-title">{{ bangumi.official_title }}</div>
<div class="card-tags">
<template v-for="i in ['season', 'group_name', 'subtitle']" :key="i">
<ab-tag
v-if="bangumi[i]"
:title="i === 'season' ? `Season ${bangumi[i]}` : bangumi[i]"
@@ -99,14 +82,170 @@ defineEmits(['click']);
</template>
</div>
</div>
<ab-add
my-auto
:round="true"
type="medium"
@click="() => $emit('click')"
/>
</div>
<ab-add :round="true" type="medium" @click="() => $emit('click')" />
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
// Grid poster card
.card {
width: 150px;
cursor: pointer;
user-select: none;
}
.card-poster {
position: relative;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-md);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
.card:hover & {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
}
.card-img {
width: 100%;
height: 210px;
object-fit: cover;
display: block;
}
.card-placeholder {
width: 100%;
height: 210px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-hover);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
transition: background-color var(--transition-normal);
&--small {
height: 44px;
}
}
.card-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(2px);
transition: opacity var(--transition-normal);
.card:hover & {
opacity: 1;
}
.card:active & {
background: rgba(0, 0, 0, 0.5);
}
}
.card-edit-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md);
transition: transform var(--transition-fast);
.card:active & {
transform: scale(0.9);
}
}
.card-info {
padding: 8px 2px 4px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
transition: color var(--transition-normal);
}
.card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
// Search result card
.search-card {
width: 480px;
border-radius: var(--radius-lg);
padding: 4px;
background: var(--color-primary-light);
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal);
}
.search-card-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
transition: background-color var(--transition-normal);
}
.search-card-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.search-card-thumb {
width: 72px;
height: 44px;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.search-card-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.search-card-meta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.search-card-title {
font-size: 14px;
font-weight: 500;
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -10,24 +10,50 @@ withDefaults(
</script>
<template>
<div rounded-10 overflow-hidden>
<div
bg-theme-row
w-full
text-white
px="10 pc:20"
h="38 pc:45"
fx-cer
justify-between
select-none
>
<div text="h3 pc:h2">{{ title }}</div>
<div class="container-card">
<div class="container-header">
<div class="container-title">{{ title }}</div>
<slot name="title-right"></slot>
</div>
<div p="14 pc:20" bg-white text="14 inherit">
<div class="container-body">
<slot></slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.container-card {
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.container-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 34px;
background: transparent;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border);
user-select: none;
transition: color var(--transition-normal),
border-color var(--transition-normal);
}
.container-title {
font-size: 15px;
font-weight: 600;
}
.container-body {
padding: 12px 14px;
background: var(--color-surface);
color: var(--color-text);
font-size: 14px;
transition: background-color var(--transition-normal),
color var(--transition-normal);
}
</style>

View File

@@ -5,42 +5,66 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
withDefaults(
defineProps<{
title: string;
defaultOpen?: boolean;
}>(),
{
title: 'title',
defaultOpen: true,
}
);
</script>
<template>
<Disclosure v-slot="{ open }">
<div rounded-10 overflow-hidden h-max>
<DisclosureButton
bg-theme-row
w-full
text-white
fx-cer
px="10 pc:20"
h="38 pc:45"
justify-between
>
<div text="h3 pc:h2">{{ title }}</div>
<Component :is="open ? Up : Down" size="24" />
<Disclosure v-slot="{ open }" :default-open="defaultOpen">
<div class="fold-panel">
<DisclosureButton class="fold-panel-header">
<div class="fold-panel-title">{{ title }}</div>
<Component :is="open ? Up : Down" :size="14" />
</DisclosureButton>
<div
bg-white
py="10 pc:20"
text="14 inherit"
:class="[open ? 'px-20' : 'px-8']"
>
<div v-show="!open" line my-12></div>
<DisclosurePanel>
<DisclosurePanel>
<div class="fold-panel-body">
<slot></slot>
</DisclosurePanel>
</div>
</div>
</DisclosurePanel>
</div>
</Disclosure>
</template>
<style lang="scss" scoped>
.fold-panel {
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.fold-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 14px;
height: 34px;
background: transparent;
color: var(--color-text-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: color var(--transition-normal),
border-color var(--transition-normal);
}
.fold-panel-title {
font-size: 15px;
font-weight: 600;
}
.fold-panel-body {
background: var(--color-surface);
padding: 12px 14px;
font-size: 14px;
color: var(--color-text);
transition: background-color var(--transition-normal),
color var(--transition-normal);
}
</style>

View File

@@ -15,9 +15,23 @@ const abLabel = computed(() => {
</script>
<template>
<div flex="~ items-start justify-between">
<div>{{ abLabel }}</div>
<div class="label-row">
<div class="label-text">{{ abLabel }}</div>
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
}
.label-text {
font-size: 14px;
color: var(--color-text);
transition: color var(--transition-normal);
}
</style>

View File

@@ -30,7 +30,7 @@ function close() {
<template>
<TransitionRoot appear :show="show" as="template">
<Dialog as="div" class="relative z-10" @close="close">
<Dialog as="div" class="popup-dialog" @close="close">
<TransitionChild
as="template"
enter="duration-300 ease-out"
@@ -40,11 +40,11 @@ function close() {
leave-from="opacity-100"
leave-to="opacity-0"
>
<div fixed inset-0 bg="black opacity-25" />
<div class="popup-backdrop" />
</TransitionChild>
<div fixed inset-0 overflow-y-auto>
<div flex="~ items-center justify-center" min-h-full p-4 text-center>
<div class="popup-wrapper">
<div class="popup-center">
<TransitionChild
as="template"
enter="duration-300 ease-out"
@@ -65,3 +65,50 @@ function close() {
</Dialog>
</TransitionRoot>
</template>
<style lang="scss" scoped>
.popup-dialog {
position: relative;
z-index: 40;
}
.popup-backdrop {
position: fixed;
inset: 0;
background: rgba(108, 74, 182, 0.15);
backdrop-filter: blur(4px);
}
.popup-wrapper {
position: fixed;
inset: 0;
overflow-y: auto;
}
.popup-center {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 16px;
text-align: center;
}
:deep(.container-card) {
border: 1px solid var(--color-primary);
box-shadow: 0 8px 32px rgba(108, 74, 182, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
border-radius: var(--radius-lg);
overflow: hidden;
}
:deep(.container-header) {
background: var(--color-primary);
color: #fff;
border-bottom: none;
height: 38px;
}
:deep(.container-body) {
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
</style>

View File

@@ -1,102 +1,112 @@
<script lang="ts" setup>
import {
Popover,
PopoverButton,
PopoverOverlay,
PopoverPanel,
} from '@headlessui/vue';
import { vOnClickOutside } from '@vueuse/components';
import { Search } from '@icon-park/vue-next';
import type { BangumiRule } from '#/bangumi';
defineEmits<{
(e: 'add-bangumi', bangumiRule: BangumiRule): void;
}>();
const { providers, provider, loading, keyword, searchData } = storeToRefs(
const showProvider = ref(false);
const { providers, provider, loading, inputValue, bangumiList } = storeToRefs(
useSearchStore()
);
const { getProviders, clearSearch, openSearch } = useSearchStore();
const { getProviders, onSearch, clearSearch } = useSearchStore();
onMounted(() => {
getProviders();
});
function onSelect(site: string) {
provider.value = site;
showProvider.value = false;
}
</script>
<template>
<Popover v-bind="$attrs">
<transition name="fade">
<PopoverOverlay
class="fixed top-0 left-0 w-full h-full bg-black bg-opacity-50 z-5"
/>
</transition>
<ab-search
v-model:inputValue="inputValue"
:provider="provider"
:loading="loading"
@search="onSearch"
@select="() => (showProvider = !showProvider)"
/>
<PopoverButton bg-transparent text="pc:24 20" is-btn btn-click>
<Search size="1em" fill="#fff" />
</PopoverButton>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y--20 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y--20 opacity-0"
<transition name="dropdown">
<div
v-show="showProvider"
v-on-click-outside="() => (showProvider = false)"
class="provider-dropdown"
>
<PopoverPanel
v-on-click-outside="clearSearch"
class="search-panel"
fixed
left-0
right-0
m-auto
w-max
z-5
<div
v-for="site in providers"
:key="site"
class="provider-item"
@click="() => onSelect(site)"
>
<ab-search
v-model:input-value="keyword"
v-model:provider="provider"
:providers="providers"
:loading="loading"
@search="openSearch"
/>
{{ site }}
</div>
</div>
</transition>
<div class="search-list" space-y-10 overflow-auto>
<transition-group name="fade-list">
<template v-for="i in searchData" :key="i.rss_link">
<ab-bangumi-card
:bangumi="i"
type="search"
@click="() => $emit('add-bangumi', i)"
/>
</template>
</transition-group>
</div>
</PopoverPanel>
</transition>
</Popover>
<div v-on-click-outside="clearSearch" class="search-results">
<transition-group name="fade-list" tag="ul" class="search-results-list">
<li v-for="bangumi in bangumiList" :key="bangumi.order">
<ab-bangumi-card
:bangumi="bangumi.value"
type="search"
@click="() => $emit('add-bangumi', bangumi.value)"
/>
</li>
</transition-group>
</div>
</template>
<style lang="scss" scoped>
.search-panel {
--_offset-top: 80px;
--_offset-bottom: 40px;
--_search-input-height: 36px;
--_search-list-offset: 20px;
.provider-dropdown {
position: absolute;
top: 84px;
left: 540px;
width: 120px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: 50;
overflow: hidden;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
@include forMobile {
--_offset-top: 65px;
--_search-list-offset: 10px;
}
.provider-item {
padding: 10px 12px;
font-size: 14px;
color: var(--color-primary);
cursor: pointer;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background-color var(--transition-fast), color var(--transition-fast);
top: var(--_offset-top);
.search-list {
margin-top: var(--_search-list-offset);
max-height: calc(
100vh - var(--_offset-top) - var(--_offset-bottom) -
var(--_search-input-height) - var(--_search-list-offset)
);
&:hover {
background: var(--color-primary);
color: #fff;
}
}
.search-results {
position: absolute;
top: 84px;
left: 192px;
z-index: 30;
}
.search-results-list {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
padding: 0;
margin: 0;
}
</style>

View File

@@ -40,6 +40,6 @@ const data = defineModel<any>('data');
</div>
</ab-label>
<div v-if="bottomLine" line my-12></div>
<div v-if="bottomLine" line my-6></div>
</div>
</template>

View File

@@ -33,67 +33,152 @@ function abLabel(label: string | (() => string)) {
<template>
<Menu>
<div rel>
<div fx-cer space-x="pc:16 10" text="pc:24 20">
<International
theme="outline"
size="1em"
fill="#fff"
is-btn
btn-click
<div class="status-bar">
<div class="status-bar-actions">
<button
class="status-bar-btn"
aria-label="Switch language"
@click="() => $emit('changeLang')"
/>
>
<International theme="outline" size="1em" />
</button>
<AddOne
theme="outline"
size="1em"
fill="#fff"
is-btn
btn-click
<button
class="status-bar-btn"
aria-label="Add RSS subscription"
@click="() => $emit('clickAdd')"
/>
>
<AddOne theme="outline" size="1em" />
</button>
<MenuButton bg-transparent is-btn btn-click>
<System theme="outline" size="1em" fill="#fff" />
<MenuButton class="status-bar-btn" aria-label="System menu">
<System theme="outline" size="1em" />
</MenuButton>
<ab-status :running="running" />
</div>
<MenuItems
abs
top="pc:50 40"
left="pc:32 0"
w-120
rounded-8
bg-white
overflow-hidden
shadow
z-99
>
<MenuItems class="status-menu">
<MenuItem v-for="i in items" :key="i.id" v-slot="{ active }">
<div
w-full
h-32
px-12
fx-cer
gap-x-8
is-btn
hover="text-white bg-primary"
class="group"
:class="[active ? 'text-white bg-theme-row' : 'text-black']"
class="status-menu-item"
:class="[active && 'status-menu-item--active']"
@click="() => i.handle && i.handle()"
>
<div
group-hover="text-white"
:class="[active ? 'text-white' : 'text-primary']"
>
<div class="status-menu-item-icon">
<Component :is="i.icon" size="16"></Component>
</div>
<div text-main>{{ abLabel(i.label) }}</div>
<div class="status-menu-item-label">{{ abLabel(i.label) }}</div>
</div>
</MenuItem>
</MenuItems>
</div>
</Menu>
</template>
<style lang="scss" scoped>
.status-bar {
position: relative;
}
.status-bar-actions {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
@include forMobile {
gap: 8px;
font-size: 18px;
}
}
.status-bar-btn {
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast), transform var(--transition-fast);
display: flex;
align-items: center;
background: transparent;
border: none;
padding: 4px;
border-radius: var(--radius-sm);
&:hover {
color: var(--color-primary);
transform: scale(1.1);
}
&:active {
transform: scale(1);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.status-menu {
position: absolute;
top: 40px;
right: 0;
width: 160px;
padding: 4px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: 50;
overflow: hidden;
animation: dropdown-in 150ms ease-out;
transform-origin: top right;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
@include forPC {
top: 44px;
}
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.status-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 10px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text);
transition: color var(--transition-fast), background-color var(--transition-fast);
&:hover,
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
.status-menu-item-icon {
color: var(--color-primary);
display: flex;
align-items: center;
}
.status-menu-item-label {
font-size: 12px;
}
</style>

View File

@@ -15,60 +15,95 @@ defineEmits(['click']);
const buttonSize = computed(() => {
switch (props.type) {
case 'large':
return 'wh-36';
return 'add-btn--large';
case 'medium':
return 'wh-24';
return 'add-btn--medium';
case 'small':
return 'wh-12';
}
});
const lineSize = computed(() => {
switch (props.type) {
case 'large':
return 'w-18 h-4';
case 'medium':
return 'w-3 h-12';
case 'small':
return 'w-2 h-6';
return 'add-btn--small';
}
});
</script>
<template>
<button
:rounded="round ? '1/2' : '8'"
f-cer
rel
transition-colors
class="box"
:class="[`type-${type}`, buttonSize]"
class="add-btn"
:class="[buttonSize, round && 'add-btn--round']"
aria-label="Add"
@click="$emit('click')"
>
<div :class="[`type-${type}`, lineSize]" class="line" abs />
<div :class="[`type-${type}`, lineSize]" class="line" abs rotate-90></div>
<svg viewBox="0 0 24 24" fill="none" class="add-btn-icon">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</button>
</template>
<style lang="scss" scoped>
$normal: #4e3c94;
$hover: #281e52;
$active: #8e8a9c;
.box {
background: $normal;
.add-btn {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
transition: background-color var(--transition-fast),
transform var(--transition-fast);
&:hover {
background: $hover;
background: var(--color-primary-hover);
}
&:active {
background: $normal;
transform: scale(0.92);
}
&--round {
border-radius: 50%;
}
&--large {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
.add-btn-icon {
width: 18px;
height: 18px;
}
}
&--medium {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
.add-btn-icon {
width: 14px;
height: 14px;
}
}
&--small {
width: 20px;
height: 20px;
border-radius: 4px;
.add-btn-icon {
width: 10px;
height: 10px;
}
}
&--round.add-btn--large {
border-radius: 50%;
}
&--round.add-btn--medium {
border-radius: 50%;
}
&--round.add-btn--small {
border-radius: 50%;
}
}
.line {
border-radius: 1px;
background: #fff;
}
</style>

View File

@@ -22,93 +22,38 @@ defineEmits(['click']);
const selected = ref<string>(props.selections[0]);
const showSelections = ref<boolean>(false);
const buttonSize = computed(() => {
switch (props.size) {
case 'big':
return 'rounded-10 text-h1 w-276 h-55 text-h1';
case 'normal':
return 'rounded-6 w-170 h-36';
case 'small':
return 'rounded-6 w-86 h-28 text-main';
}
});
const selectboxSize = computed(() => {
switch (props.size) {
case 'big':
return 'w-276 rounded-10 text-h1';
case 'normal':
return 'w-170 rounded-6';
case 'small':
return 'w-86 rounded-6 text-main';
}
});
const loadingSize = computed(() => {
switch (props.size) {
case 'big':
return 'large';
case 'normal':
return 'small';
case 'small':
return 18;
}
});
function onSelect(selection: string) {
selected.value = selection;
showSelections.value = false;
console.log(selected.value);
}
</script>
<template>
<div :class="buttonSize" f-cer overflow-hidden>
<div class="btn-multi" :class="[`btn-multi--${size}`, `btn-multi--${type}`]">
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
text-white
outline-none
wh-full
pl-12
:class="[`type-${type}`]"
class="btn-multi-main"
@click="$emit('click', selected)"
>
<NSpin :show="loading" :size="loadingSize">
<div text-main>{{ selected }}</div>
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<div class="btn-multi-label">{{ selected }}</div>
</NSpin>
</Component>
<div
is-btn
px-12
h-full
f-cer
:class="[`selector-${type}`]"
class="btn-multi-arrow"
@click="() => (showSelections = !showSelections)"
>
<Down fill="white" />
</div>
</div>
<div
v-if="showSelections"
abs
z-70
:class="selectboxSize"
overflow-hidden
class="select-box"
class="btn-multi-dropdown"
:class="[`btn-multi--${size}`, `btn-multi--${type}`]"
>
<div
v-for="selection in selections"
:key="selection"
is-btn
wh-full
f-cer
text-main
py-8
text-white
:class="[`type-${type}`]"
@click="onSelect(selection)"
class="btn-multi-option"
@click="() => { selected = selection; showSelections = false; }"
>
{{ selection }}
</div>
@@ -116,25 +61,108 @@ function onSelect(selection: string) {
</template>
<style lang="scss" scoped>
.type {
&-primary {
@include bg-mouse-event(#4e3c94, #281e52, #8e8a9c);
.btn-multi {
display: flex;
align-items: center;
overflow: hidden;
color: #fff;
&--big {
border-radius: var(--radius-md);
width: 276px;
height: 55px;
font-size: 24px;
}
&-warn {
@include bg-mouse-event(#943c61, #521e2a, #9c8a93);
&--normal {
border-radius: var(--radius-sm);
width: 170px;
height: 36px;
font-size: 14px;
}
}
.selector {
&-primary {
@include bg-mouse-event(#4e3c94, #281e52, #8e8a9c);
&--small {
border-radius: var(--radius-sm);
width: 86px;
height: 28px;
font-size: 12px;
}
&-warn {
@include bg-mouse-event(#943c61, #521e2a, #9c8a93);
&--primary {
.btn-multi-main,
.btn-multi-arrow,
.btn-multi-option {
background: var(--color-primary);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover,
.btn-multi-option:hover {
background: var(--color-primary-hover);
}
}
&--warn {
.btn-multi-main,
.btn-multi-arrow,
.btn-multi-option {
background: var(--color-danger);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover,
.btn-multi-option:hover {
filter: brightness(0.9);
}
}
}
.select-box {
transform: TranslateY(80%) TranslateX(-111%);
.btn-multi-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding-left: 12px;
border: none;
outline: none;
color: inherit;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.btn-multi-label {
font-size: inherit;
}
.btn-multi-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
height: 100%;
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast);
}
.btn-multi-dropdown {
position: absolute;
z-index: 70;
overflow: hidden;
transform: translateY(80%) translateX(-111%);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.btn-multi-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 0;
cursor: pointer;
user-select: none;
color: #fff;
font-size: inherit;
transition: background-color var(--transition-fast);
}
</style>

View File

@@ -3,7 +3,7 @@ import { NSpin } from 'naive-ui';
const props = withDefaults(
defineProps<{
type?: 'primary' | 'warn';
type?: 'primary' | 'secondary' | 'warn';
size?: 'big' | 'normal' | 'small';
link?: string | null;
loading?: boolean;
@@ -21,22 +21,11 @@ defineEmits(['click']);
const buttonSize = computed(() => {
switch (props.size) {
case 'big':
return 'rounded-10 text-h1 w-276 h-55 text-h1';
return 'btn--big';
case 'normal':
return 'rounded-6 w-170 h-36';
return 'btn--normal';
case 'small':
return 'rounded-6 w-86 h-28 text-main';
}
});
const loadingSize = computed(() => {
switch (props.size) {
case 'big':
return 'large';
case 'normal':
return 'small';
case 'small':
return 18;
return 'btn--small';
}
});
</script>
@@ -45,26 +34,96 @@ const loadingSize = computed(() => {
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
text-white
outline-none
f-cer
:class="[`type-${type}`, buttonSize]"
class="btn"
:class="[`btn--${type}`, buttonSize]"
@click="$emit('click')"
>
<NSpin :show="loading" :size="loadingSize">
<slot></slot>
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<span class="btn-content">
<slot></slot>
</span>
</NSpin>
</Component>
</template>
<style lang="scss" scoped>
.type {
&-primary {
@include bg-mouse-event(#4e3c94, #281e52, #8e8a9c);
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
color: #fff;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
&:active {
transform: scale(0.97);
}
&-warn {
@include bg-mouse-event(#943c61, #521e2a, #9c8a93);
// Sizes
&--big {
border-radius: var(--radius-md);
font-size: 18px;
width: 276px;
height: 55px;
}
&--normal {
border-radius: var(--radius-sm);
font-size: 14px;
width: 170px;
height: 36px;
}
&--small {
border-radius: var(--radius-sm);
font-size: 12px;
min-width: 86px;
height: 28px;
padding: 0 12px;
gap: 4px;
white-space: nowrap;
}
// Types
&--primary {
background: var(--color-primary);
&:hover {
background: var(--color-primary-hover);
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
}
&-content {
display: inline-flex;
align-items: center;
gap: 4px;
}
&--secondary {
background: var(--color-surface);
color: var(--color-primary);
border: 1px solid var(--color-border);
&:hover {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-color: var(--color-primary);
}
}
&--warn {
background: var(--color-danger);
&:hover {
filter: brightness(0.9);
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-danger) 30%, transparent);
}
}
}
</style>

View File

@@ -15,28 +15,22 @@ const checked = defineModel<boolean>({ default: false });
<template>
<Switch v-model="checked" as="template">
<div flex="~ items-center gap-x-8" is-btn>
<div class="checkbox-wrapper">
<slot name="before"></slot>
<div
rel
f-cer
bg-white
border="solid #3c239f"
class="checkbox"
:class="[
small ? 'wh-16 border-2 rounded-4' : 'wh-32 border-4 rounded-6',
!checked && 'group',
small ? 'checkbox--small' : 'checkbox--normal',
checked && 'checkbox--checked',
]"
>
<div
rounded-2
transition="all duration-300"
class="checkbox-inner"
:class="[
small ? 'wh-8' : 'wh-16',
checked ? 'bg-[#3c239f]' : 'bg-transparent',
small ? 'checkbox-inner--small' : 'checkbox-inner--normal',
checked && 'checkbox-inner--checked',
]"
group-hover:bg="#cccad4"
group-active:bg="#3c239f"
></div>
</div>
@@ -44,3 +38,68 @@ const checked = defineModel<boolean>({ default: false });
</div>
</Switch>
</template>
<style lang="scss" scoped>
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.checkbox {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
border: 2px solid var(--color-primary);
transition: border-color var(--transition-fast),
background-color var(--transition-fast);
&--normal {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
border-width: 2px;
}
&--small {
width: 16px;
height: 16px;
border-radius: 4px;
border-width: 2px;
}
&--checked {
border-color: var(--color-primary);
}
&:hover:not(.checkbox--checked) {
.checkbox-inner {
background: var(--color-border-hover);
}
}
}
.checkbox-inner {
border-radius: 2px;
transition: background-color var(--transition-fast);
background: transparent;
&--normal {
width: 12px;
height: 12px;
}
&--small {
width: 8px;
height: 8px;
}
&--checked {
background: var(--color-primary);
}
}
</style>

View File

@@ -10,8 +10,41 @@ withDefaults(
</script>
<template>
<div fx-cer gap-x-12>
<div text="pc:h1 h2">{{ title }}</div>
<div w-160 h-3 bg-theme-row rounded-full></div>
<div class="page-title">
<h1 class="page-title-text">{{ title }}</h1>
<div class="page-title-accent"></div>
</div>
</template>
<style lang="scss" scoped>
.page-title {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.page-title-text {
font-size: 22px;
font-weight: 600;
color: var(--color-text);
margin: 0;
transition: color var(--transition-normal);
@include forMobile {
font-size: 18px;
}
}
.page-title-accent {
width: 120px;
height: 3px;
border-radius: var(--radius-full);
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover));
opacity: 0.6;
@include forMobile {
width: 80px;
}
}
</style>

View File

@@ -4,103 +4,131 @@ import { NSpin } from 'naive-ui';
withDefaults(
defineProps<{
providers: string[];
provider: string;
loading: boolean;
}>(),
{
provider: '',
loading: false,
}
);
defineEmits(['search']);
defineEmits(['select', 'search']);
const provider = defineModel<string>('provider');
const inputValue = defineModel<string>('inputValue');
const showProvider = ref(false);
function onSelect(site: string) {
provider.value = site;
showProvider.value = false;
}
</script>
<template>
<div
bg="#7752B4"
text-white
fx-cer
rounded-12
h-36
pl-12
gap-x-12
w-400
max-w-90vw
shadow-inner
>
<Search
<div class="search-input" role="search">
<button
v-if="!loading"
theme="outline"
size="24"
fill="#fff"
is-btn
btn-click
class="search-icon-btn"
aria-label="Search"
@click="$emit('search')"
/>
<NSpin v-else :size="20" />
>
<Search theme="outline" size="20" class="search-icon" />
</button>
<NSpin v-else :size="18" />
<input
v-model="inputValue"
type="text"
:placeholder="$t('topbar.search.placeholder')"
input-reset
class="search-field"
aria-label="Search anime"
@keyup.enter="$emit('search')"
/>
<div rel w-100 h-full px-12 rounded-inherit class="provider" is-btn>
<div
fx-cer
wh-full
justify-between
@click="() => (showProvider = !showProvider)"
>
<div text-h3 truncate>
{{ provider }}
</div>
<Down />
</div>
<div
v-show="showProvider"
abs
top="100%"
left-0
w-100
rounded-12
shadow
bg-white
z-1
overflow-hidden
>
<div
v-for="site in providers"
:key="site"
hover:bg-theme-row
is-btn
@click="() => onSelect(site)"
>
<div text="h3 primary" hover="text-white" p-12 truncate>
{{ site }}
</div>
</div>
</div>
</div>
<button
class="search-provider"
aria-label="Select search provider"
@click="$emit('select')"
>
<div class="search-provider-label">{{ provider }}</div>
<Down :size="14" />
</button>
</div>
</template>
<style lang="scss" scoped>
.provider {
background: #4e2a94;
.search-input {
display: flex;
align-items: center;
height: 36px;
padding-left: 12px;
gap: 10px;
width: 360px;
border-radius: var(--radius-md);
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
overflow: hidden;
transition: border-color var(--transition-fast),
background-color var(--transition-normal);
&:focus-within {
border-color: var(--color-primary);
background: var(--color-surface);
}
}
.search-icon-btn {
display: flex;
align-items: center;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.search-icon {
color: var(--color-text-muted);
transition: color var(--transition-fast);
.search-icon-btn:hover & {
color: var(--color-primary);
}
}
.search-field {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--color-text);
&::placeholder {
color: var(--color-text-muted);
}
}
.search-provider {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 100%;
padding: 0 12px;
min-width: 80px;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
user-select: none;
font-size: 13px;
font-family: inherit;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
}
.search-provider-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -64,26 +64,17 @@ watchEffect(() => {
<template>
<Listbox v-slot="{ open }" v-model="selected">
<div
rel
flex="inline col"
rounded-6
border="1 black"
text-main
p="y-4 x-12"
>
<ListboxButton bg-transparent fx-cer justify-between gap-x-24>
<div>
{{ label }}
</div>
<div class="select-wrapper">
<ListboxButton class="select-button">
<div class="select-value">{{ label }}</div>
<div :class="[{ hidden: open }]">
<Down />
<Down :size="14" />
</div>
</ListboxButton>
<ListboxOptions mt-8>
<div flex="~ items-end justify-between gap-x-24">
<div flex="~ col gap-y-8">
<ListboxOptions class="select-options">
<div class="select-options-inner">
<div class="select-options-list">
<ListboxOption
v-for="item in otherItems"
v-slot="{ active }"
@@ -92,9 +83,10 @@ watchEffect(() => {
:disabled="getDisabled(item)"
>
<div
class="select-option"
:class="[
{ 'text-primary': active },
getDisabled(item) ? 'is-disabled' : 'is-btn',
active && 'select-option--active',
getDisabled(item) && 'select-option--disabled',
]"
>
{{ getLabel(item) }}
@@ -102,9 +94,75 @@ watchEffect(() => {
</ListboxOption>
</div>
<div :class="[{ hidden: !open }]"><Up /></div>
<div :class="[{ hidden: !open }]"><Up :size="14" /></div>
</div>
</ListboxOptions>
</div>
</Listbox>
</template>
<style lang="scss" scoped>
.select-wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
font-size: 12px;
padding: 4px 12px;
transition: border-color var(--transition-fast);
&:hover {
border-color: var(--color-primary);
}
}
.select-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
.select-value {
color: var(--color-text);
}
.select-options {
margin-top: 8px;
}
.select-options-inner {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.select-options-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.select-option {
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast);
&--active {
color: var(--color-primary);
}
&--disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
</style>

View File

@@ -12,14 +12,59 @@ withDefaults(
</script>
<template>
<div f-cer :style="{ width: size, height: size }">
<div rounded="1/2" f-cer border="2 solid white" wh-full>
<div
class="status-indicator"
:style="{ width: size, height: size }"
role="status"
:aria-label="running ? 'System running' : 'System stopped'"
>
<div class="status-ring">
<div
:class="[running ? 'bg-running' : 'bg-stopped']"
rounded="1/2"
wh-10
transition-colors
class="status-dot"
:class="[running ? 'status-dot--running' : 'status-dot--stopped']"
></div>
</div>
</div>
</template>
<style lang="scss" scoped>
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
}
.status-ring {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 2px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
transition: background-color var(--transition-fast);
&--running {
background: var(--color-success);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);
animation: pulse 2s ease-in-out infinite;
}
&--stopped {
background: var(--color-danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>

View File

@@ -8,54 +8,44 @@ const checked = defineModel<boolean>('checked', {
<template>
<Switch v-model="checked" as="template">
<div
is-btn
w-48
h-28
rounded-full
rel
flex="inline items-center"
transition-colors
duration-300
p-3
shadow="~ inset"
class="box"
:class="{ checked }"
>
<div class="switch-track" :class="{ 'switch-track--checked': checked }">
<div
wh-22
rounded="1/2"
transition-all
duration-300
class="slider"
:class="{ checked, 'translate-x-20': checked }"
class="switch-thumb"
:class="{ 'switch-thumb--checked': checked }"
></div>
</div>
</Switch>
</template>
<style lang="scss" scope>
$bg-unchecked: #929292;
$bg-checked: #1c1259;
<style lang="scss" scoped>
.switch-track {
width: 44px;
height: 24px;
border-radius: var(--radius-full);
position: relative;
display: inline-flex;
align-items: center;
padding: 2px;
cursor: pointer;
user-select: none;
background: var(--color-border-hover);
transition: background-color var(--transition-fast);
$slider-unchecked: #ececef;
$slider-checked: #fff;
.box {
background: $bg-unchecked;
&.checked {
background: $bg-checked;
&--checked {
background: var(--color-primary);
}
}
.slider {
&:not(.checked) {
background: $slider-unchecked;
}
.switch-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform var(--transition-fast);
&.checked {
background: $slider-checked;
&--checked {
transform: translateX(20px);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
const props = withDefaults(
withDefaults(
defineProps<{
type: 'primary' | 'warn' | 'inactive' | 'active' | 'notify';
title: string;
@@ -9,67 +9,59 @@ const props = withDefaults(
title: 'title',
}
);
const InnerStyle = computed(() => {
return `${props.type}-inner`;
});
</script>
<template>
<div p-1 rounded-16 inline-flex w-max :class="type">
<div w-full bg-white rounded-12 px-8 text-10 truncate :class="InnerStyle">
{{ title }}
</div>
</div>
<span class="tag" :class="`tag--${type}`">
{{ title }}
</span>
</template>
<style lang="scss" scoped>
$primary-map: (
border: linear-gradient(90.5deg, #492897 1.53%, #783674 96.48%),
inner: #eee5f4,
font: #000000,
);
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 500;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid;
transition: background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
$warn-map: (
border: #892f2f,
inner: #ffdfdf,
font: #892f2f,
);
&--primary {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
$inactive-map: (
border: #797979,
inner: #e0e0e0,
font: #3f3f3f,
);
&--warn {
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: var(--color-danger);
color: var(--color-danger);
}
$active-map: (
border: #104931,
inner: #e5f4e0,
font: #4c6643,
);
&--inactive {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text-muted);
}
$notify-map: (
border: #f5c451,
inner: #fff4db,
font: #a76e18,
);
&--active {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: var(--color-success);
color: var(--color-success);
}
$types-map: (
primary: $primary-map,
warn: $warn-map,
inactive: $inactive-map,
active: $active-map,
notify: $notify-map,
);
@each $type, $colors in $types-map {
.#{$type} {
background: map-get($colors, border);
&-inner {
background: map-get($colors, inner);
color: map-get($colors, font);
}
&--notify {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border-color: var(--color-warning);
color: var(--color-warning);
}
}
</style>

View File

@@ -6,8 +6,10 @@ import {
Log,
Logout,
MenuUnfold,
Moon,
Play,
SettingTwo,
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
@@ -24,14 +26,15 @@ const { t } = useMyI18n();
const { logout } = useAuth();
const route = useRoute();
const { isMobile } = useBreakpointQuery();
const { isDark, toggle: toggleDark } = useDarkMode();
const show = ref(props.open);
const toggle = () => (show.value = !show.value);
const RSS = h(
'span',
{ class: ['rel', 'left-2'] },
h(InlineSvg, { src: './images/RSS.svg' })
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '20px', height: '20px' } },
h(InlineSvg, { src: './images/RSS.svg', width: '16', height: '16' })
);
const items = [
@@ -46,7 +49,6 @@ const items = [
icon: Calendar,
label: () => t('sidebar.calendar'),
path: '/calendar',
hidden: true,
},
{
id: 3,
@@ -86,21 +88,13 @@ function Exit() {
<div
title="logout"
class={[
`
mt-auto
fx-cer
gap-x-42
px-24
is-btn
transition-colors
`,
isMobile.value ? 'h-40' : 'h-48',
'sidebar-item sidebar-item--action',
isMobile.value ? 'h-40' : '',
]}
hover="bg-[#F1F5FA] text-[#2A1C52]"
onClick={logout}
>
<Logout size={24} />
{!isMobile.value && <div class="text-h2">{t('sidebar.logout')}</div>}
<Logout size={20} />
{!isMobile.value && show.value && <div class="sidebar-item-label">{t('sidebar.logout')}</div>}
</div>
);
}
@@ -111,91 +105,261 @@ const mobileItems = computed(() => items.filter((i) => i.id !== 4));
<template>
<media-query>
<div
:class="[show ? 'w-240' : 'w-72']"
bg-theme-col
text-white
transition-width
pb-12
rounded="pc:16 10"
class="sidebar"
:class="[show ? 'sidebar--expanded' : 'sidebar--collapsed']"
>
<div overflow-hidden wh-full flex="~ col">
<div
w-full
h-60
is-btn
f-cer
rounded-t-10
bg="#E7E7E7"
text="#2A1C52"
rel
<div class="sidebar-inner">
<!-- Toggle header -->
<button
class="sidebar-header"
:aria-label="show ? 'Collapse sidebar' : 'Expand sidebar'"
:aria-expanded="show"
@click="toggle"
>
<div :class="[!show && 'abs opacity-0']" transition-opacity>
<div text-h1>{{ $t('sidebar.title') }}</div>
<div v-show="show" class="sidebar-title">
{{ $t('sidebar.title') }}
</div>
<MenuUnfold
theme="outline"
size="24"
fill="#2A1C52"
abs
left-24
:class="[show && 'rotate-y-180']"
size="20"
class="sidebar-toggle-icon"
:class="[show && 'sidebar-toggle-icon--open']"
/>
</button>
<!-- Navigation -->
<nav class="sidebar-nav">
<RouterLink
v-for="i in items"
:key="i.id"
:to="i.path"
replace
:title="i.label()"
class="sidebar-item"
:class="[
route.path === i.path && 'sidebar-item--active',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="20" />
<div v-show="show" class="sidebar-item-label">{{ i.label() }}</div>
</RouterLink>
</nav>
<!-- Bottom actions -->
<div class="sidebar-footer">
<button
class="sidebar-item sidebar-item--action sidebar-item--theme"
:title="isDark ? 'Light mode' : 'Dark mode'"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="20" />
<Sun v-else :size="20" />
<div v-show="show" class="sidebar-item-label">
{{ isDark ? 'Light' : 'Dark' }}
</div>
</button>
<Exit />
</div>
<RouterLink
v-for="i in items"
:key="i.id"
:to="i.path"
replace
:title="i.label()"
fx-cer
px-24
gap-x-42
h-48
is-btn
transition-colors
hover="bg-[#F1F5FA] text-[#2A1C52]"
:class="[
route.path === i.path && 'bg-[#F1F5FA] text-[#2A1C52]',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="24" />
<div text-h2 whitespace-nowrap>{{ i.label() }}</div>
</RouterLink>
<Exit />
</div>
</div>
<template #mobile>
<div bg-white flex rounded-10 overflow-hidden>
<div class="mobile-nav">
<RouterLink
v-for="i in mobileItems"
:key="i.id"
:to="i.path"
replace
flex-1
fx-cer
px-24
gap-x-42
h-40
is-btn
transition-colors
rounded-10
class="mobile-nav-item"
:class="[
route.path === i.path && 'bg-theme-row text-white',
route.path === i.path && 'mobile-nav-item--active',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="24" />
<Component :is="i.icon" :size="20" />
</RouterLink>
<div
class="mobile-nav-item"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="20" />
<Sun v-else :size="20" />
</div>
<Exit />
</div>
</template>
</media-query>
</template>
<style lang="scss" scoped>
.sidebar {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: width var(--transition-normal),
background-color var(--transition-normal),
border-color var(--transition-normal);
overflow: hidden;
&--expanded {
width: 200px;
}
&--collapsed {
width: 64px;
}
}
.sidebar-inner {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 52px;
padding: 0 20px;
cursor: pointer;
position: relative;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
transition: border-color var(--transition-normal),
background-color var(--transition-fast);
&:hover {
background: var(--color-surface-hover);
}
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
transition: opacity var(--transition-fast);
}
.sidebar-toggle-icon {
position: absolute;
left: 20px;
color: var(--color-text-secondary);
transition: transform var(--transition-normal);
&--open {
transform: rotateY(180deg);
}
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 8px;
gap: 2px;
flex: 1;
overflow-y: auto;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast),
background-color var(--transition-fast);
white-space: nowrap;
&:hover {
color: var(--color-primary);
background: var(--color-primary-light);
}
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
font-weight: 500;
}
&--action {
color: var(--color-text-muted);
border: none;
background: transparent;
width: 100%;
font: inherit;
&:hover {
color: var(--color-danger);
background: rgba(239, 68, 68, 0.08);
}
}
&--theme:hover {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
.sidebar-item-label {
font-size: 14px;
}
.sidebar-footer {
display: flex;
flex-direction: column;
padding: 8px;
gap: 2px;
border-top: 1px solid var(--color-border);
margin-top: auto;
transition: border-color var(--transition-normal);
}
// Mobile bottom nav
.mobile-nav {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.mobile-nav-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
cursor: pointer;
user-select: none;
color: var(--color-text-muted);
border-radius: var(--radius-md);
transition: color var(--transition-fast),
background-color var(--transition-fast);
&:hover {
color: var(--color-primary);
}
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
</style>

View File

@@ -67,12 +67,12 @@ const items = [
},
];
const { isDark } = useDarkMode();
const onSearchFocus = ref(false);
function addSearchResult(bangumi: BangumiRule) {
showAddRSS.value = true;
searchRule.value = bangumi;
console.log('searchRule', searchRule.value);
}
watch(showAddRSS, (val) => {
@@ -94,31 +94,28 @@ onUnmounted(() => {
</script>
<template>
<div
h="pc:60 50"
bg-theme-row
text-white
rounded="pc:16 10"
fx-cer
px="pc:24 15"
>
<div flex="~ gap-x-16">
<div fx-cer gap-x="pc:16 10">
<img src="/images/logo-light.svg" alt="favicon" wh="pc:24 20" />
<div class="topbar">
<div class="topbar-left">
<div class="topbar-brand">
<img
:src="isDark ? '/images/logo-light.svg' : '/images/logo.svg'"
alt="favicon"
class="topbar-logo"
/>
<img
v-show="onSearchFocus === false"
src="/images/AutoBangumi.svg"
:src="isDark ? '/images/AutoBangumi.svg' : '/images/AutoBangumi-dark.svg'"
alt="AutoBangumi"
rel
h="18 pc:24"
pc:top-2
class="topbar-wordmark"
/>
</div>
<div class="topbar-search">
<ab-search-bar @add-bangumi="addSearchResult" />
</div>
</div>
<div ml-auto fx-cer>
<ab-search-bar mr="pc:16 10" fx-cer @add-bangumi="addSearchResult" />
<div class="topbar-right">
<ab-status-bar
:items="items"
:running="running"
@@ -126,8 +123,77 @@ onUnmounted(() => {
@change-lang="changeLocale"
/>
</div>
</div>
<ab-change-account v-model:show="showAccount"></ab-change-account>
<ab-add-rss v-model:show="showAddRSS" v-model:rule="searchRule"></ab-add-rss>
<ab-change-account v-model:show="showAccount"></ab-change-account>
<ab-add-rss
v-model:show="showAddRSS"
v-model:rule="searchRule"
></ab-add-rss>
</div>
</template>
<style lang="scss" scoped>
.topbar {
display: flex;
align-items: center;
height: 56px;
padding: 0 20px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal),
border-color var(--transition-normal),
box-shadow var(--transition-normal);
@include forMobile {
height: 48px;
padding: 0 12px;
border-radius: var(--radius-md);
}
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.topbar-logo {
width: 24px;
height: 24px;
@include forMobile {
width: 20px;
height: 20px;
}
}
.topbar-wordmark {
height: 20px;
position: relative;
@include forMobile {
height: 16px;
}
}
.topbar-search {
display: none;
@include forPC {
display: block;
}
}
.topbar-right {
margin-left: auto;
}
</style>

View File

@@ -65,7 +65,7 @@ const items: SettingItem<Downloader>[] = [
<template>
<ab-fold-panel :title="$t('config.downloader_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in items"
:key="i.configKey"

View File

@@ -43,7 +43,7 @@ const items: SettingItem<BangumiManage>[] = [
<template>
<ab-fold-panel :title="$t('config.manage_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in items"
:key="i.configKey"

View File

@@ -51,7 +51,7 @@ const logItems: SettingItem<Log> = {
<template>
<ab-fold-panel :title="$t('config.normal_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in programItems"
:key="i.configKey"

View File

@@ -52,7 +52,7 @@ const items: SettingItem<Notification>[] = [
<template>
<ab-fold-panel :title="$t('config.notification_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in items"
:key="i.configKey"

View File

@@ -85,7 +85,7 @@ const azureItems: SettingItem<ExperimentalOpenAI>[] = [
<span>{{ $t('config.experimental_openai_set.warning') }}</span>
</div>
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in openAI.api_type === 'azure' ? azureItems : openAIItems"
:key="i.configKey"

View File

@@ -33,7 +33,7 @@ const items: SettingItem<RssParser>[] = [
<template>
<ab-fold-panel :title="$t('config.parser_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in items"
:key="i.configKey"

View File

@@ -0,0 +1,174 @@
<script lang="ts" setup>
import { Delete } from '@icon-park/vue-next';
import type { PasskeyItem } from '#/passkey';
const { t } = useMyI18n();
const { passkeys, loading, isSupported, loadPasskeys, addPasskey, deletePasskey } =
usePasskey();
const showAddDialog = ref(false);
const deviceName = ref('');
const isRegistering = ref(false);
onMounted(() => {
loadPasskeys();
});
function openAddDialog() {
// 生成默认设备名称
const platform = navigator.platform || 'Device';
const userAgent = navigator.userAgent;
if (userAgent.includes('iPhone')) {
deviceName.value = 'iPhone';
} else if (userAgent.includes('iPad')) {
deviceName.value = 'iPad';
} else if (userAgent.includes('Mac')) {
deviceName.value = 'MacBook';
} else if (userAgent.includes('Windows')) {
deviceName.value = 'Windows PC';
} else if (userAgent.includes('Android')) {
deviceName.value = 'Android';
} else {
deviceName.value = platform;
}
showAddDialog.value = true;
}
async function handleAdd() {
if (!deviceName.value.trim()) return;
isRegistering.value = true;
try {
const success = await addPasskey(deviceName.value.trim());
if (success) {
showAddDialog.value = false;
deviceName.value = '';
}
} finally {
isRegistering.value = false;
}
}
async function handleDelete(passkey: PasskeyItem) {
if (!confirm(t('passkey.delete_confirm'))) return;
await deletePasskey(passkey.id);
}
function formatDate(dateString: string | null): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
}
</script>
<template>
<ab-fold-panel :title="$t('passkey.title')">
<div space-y-8>
<!-- 不支持提示 -->
<div v-if="!isSupported" text-orange-500 text-14>
{{ $t('passkey.not_supported') }}
</div>
<!-- 加载中 -->
<div v-else-if="loading" text-gray-500 text-14>
{{ $t('passkey.loading') }}
</div>
<!-- Passkey -->
<div v-else-if="passkeys.length === 0" text-gray-500 text-14>
{{ $t('passkey.no_passkeys') }}
</div>
<!-- Passkey 列表 -->
<div v-else space-y-8>
<div
v-for="passkey in passkeys"
:key="passkey.id"
flex="~ justify-between items-center"
p-12
bg-gray-50
rounded-8
>
<div>
<div font-medium>{{ passkey.name }}</div>
<div text-12 text-gray-500>
{{ $t('passkey.created_at') }}: {{ formatDate(passkey.created_at) }}
</div>
<div v-if="passkey.last_used_at" text-12 text-gray-500>
{{ $t('passkey.last_used') }}: {{ formatDate(passkey.last_used_at) }}
</div>
<div v-if="passkey.backup_eligible" text-12 text-green-600>
{{ $t('passkey.synced') }}
</div>
</div>
<ab-button
size="small"
type="warn"
@click="handleDelete(passkey)"
>
<Delete size="16" />
</ab-button>
</div>
</div>
<div line></div>
<!-- 添加按钮 -->
<div flex="~ justify-end">
<ab-button
v-if="isSupported"
size="small"
type="primary"
@click="openAddDialog"
>
{{ $t('passkey.add_new') }}
</ab-button>
</div>
</div>
<!-- 添加对话框 -->
<ab-popup
v-model:show="showAddDialog"
:title="$t('passkey.register_title')"
css="w-365"
>
<div space-y-16>
<ab-label :label="$t('passkey.device_name')">
<input
v-model="deviceName"
type="text"
:placeholder="$t('passkey.device_name_placeholder')"
ab-input
maxlength="64"
@keyup.enter="handleAdd"
/>
</ab-label>
<div text-14 text-gray-500>
{{ $t('passkey.register_hint') }}
</div>
<div line></div>
<div flex="~ justify-end gap-8">
<ab-button
size="small"
type="warn"
@click="showAddDialog = false"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
size="small"
type="primary"
:disabled="!deviceName.trim() || isRegistering"
@click="handleAdd"
>
{{ $t('config.apply') }}
</ab-button>
</div>
</div>
</ab-popup>
</ab-fold-panel>
</template>

View File

@@ -4,7 +4,7 @@ const { types, type, url } = storeToRefs(usePlayerStore());
<template>
<ab-fold-panel :title="$t('config.media_player_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-model:data="type"
type="select"

View File

@@ -64,7 +64,7 @@ const items: SettingItem<Proxy>[] = [
<template>
<ab-fold-panel :title="$t('config.proxy_set.title')">
<div space-y-12>
<div space-y-8>
<ab-setting
v-for="i in items"
:key="i.configKey"

View File

@@ -0,0 +1,49 @@
import { computed, ref, watch } from 'vue';
import { createSharedComposable, usePreferredDark } from '@vueuse/core';
type ThemeMode = 'light' | 'dark' | 'system';
export const useDarkMode = createSharedComposable(() => {
const prefersDark = usePreferredDark();
const stored = localStorage.getItem('theme') as ThemeMode | null;
const mode = ref<ThemeMode>(stored || 'system');
const isDark = computed(() => {
if (mode.value === 'system') return prefersDark.value;
return mode.value === 'dark';
});
function applyTheme() {
const html = document.documentElement;
if (isDark.value) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
}
function setMode(newMode: ThemeMode) {
mode.value = newMode;
if (newMode === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', newMode);
}
}
function toggle() {
setMode(isDark.value ? 'light' : 'dark');
}
watch(isDark, applyTheme, { immediate: true });
watch(prefersDark, () => {
if (mode.value === 'system') applyTheme();
});
return {
mode,
isDark,
setMode,
toggle,
};
});

View File

@@ -0,0 +1,101 @@
import { createSharedComposable } from '@vueuse/core';
import { apiPasskey } from '@/api/passkey';
import {
registerPasskey,
loginWithPasskey as webauthnLogin,
isWebAuthnSupported,
} from '@/services/webauthn';
import type { PasskeyItem } from '#/passkey';
export const usePasskey = createSharedComposable(() => {
const message = useMessage();
const { t } = useMyI18n();
const { isLoggedIn } = useAuth();
// 状态
const passkeys = ref<PasskeyItem[]>([]);
const loading = ref(false);
const isSupported = ref(false);
// 检测浏览器支持
onMounted(() => {
isSupported.value = isWebAuthnSupported();
});
// 加载 Passkey 列表
async function loadPasskeys() {
if (!isLoggedIn.value) return;
try {
loading.value = true;
passkeys.value = await apiPasskey.list();
} catch (error) {
console.error('Failed to load passkeys:', error);
} finally {
loading.value = false;
}
}
// 注册新 Passkey
async function addPasskey(deviceName: string): Promise<boolean> {
try {
await registerPasskey(deviceName);
message.success(t('passkey.register_success'));
await loadPasskeys();
return true;
} catch (error: unknown) {
// Don't show duplicate message if axios interceptor already handled it
if (error && typeof error === 'object' && 'status' in error) {
return false;
}
const errorMessage =
error instanceof Error ? error.message : String(error);
message.error(`${t('passkey.register_failed')}: ${errorMessage}`);
return false;
}
}
// 使用 Passkey 登录
async function loginWithPasskey(username: string): Promise<boolean> {
try {
await webauthnLogin(username);
isLoggedIn.value = true;
message.success(t('notify.login_success'));
return true;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'status' in error) {
return false;
}
const errorMessage =
error instanceof Error ? error.message : String(error);
message.error(`${t('passkey.login_failed')}: ${errorMessage}`);
return false;
}
}
// 删除 Passkey
async function deletePasskey(passkeyId: number): Promise<boolean> {
try {
await apiPasskey.delete({ passkey_id: passkeyId });
message.success(t('passkey.delete_success'));
await loadPasskeys();
return true;
} catch (error) {
message.error(t('passkey.delete_failed'));
return false;
}
}
return {
// 状态
passkeys,
loading,
isSupported,
// 方法
loadPasskeys,
addPasskey,
loginWithPasskey,
deletePasskey,
};
});

View File

@@ -70,9 +70,29 @@
}
},
"downloader": {
"hit": "Please set up the downloader"
"hit": "Please set up the downloader",
"empty": {
"title": "Downloader not configured",
"subtitle": "Connect your download client to manage torrents here",
"step1_title": "Open Config",
"step1_desc": "Navigate to the Config page and find the Downloader section.",
"step2_title": "Enter Connection Details",
"step2_desc": "Set your qBittorrent host address, username, and password.",
"step3_title": "Access Downloader",
"step3_desc": "Once configured, the downloader web UI will be embedded right here."
}
},
"homepage": {
"empty": {
"title": "No subscriptions yet",
"subtitle": "Get started by adding your first RSS feed",
"step1_title": "Add RSS Feed",
"step1_desc": "Click the \"Add\" button in the top bar and paste an RSS link from your anime source.",
"step2_title": "Configure Downloader",
"step2_desc": "Go to Config and set up your downloader (e.g. qBittorrent) connection.",
"step3_title": "Sit Back & Enjoy",
"step3_desc": "AutoBangumi will automatically download and rename new episodes for you."
},
"rule": {
"apply": "Apply",
"delete": "Delete",
@@ -103,10 +123,31 @@
},
"login": {
"login_btn": "Login",
"passkey_btn": "Passkey",
"password": "Password",
"title": "Login",
"username": "Username"
},
"passkey": {
"add_new": "Add Passkey",
"created_at": "Created",
"delete_confirm": "Are you sure you want to delete this passkey?",
"delete_failed": "Delete failed",
"delete_success": "Passkey deleted",
"device_name": "Device Name",
"device_name_placeholder": "e.g., iPhone 15, MacBook Pro",
"last_used": "Last used",
"loading": "Loading...",
"login_failed": "Passkey login failed",
"no_passkeys": "No passkeys registered yet",
"not_supported": "Your browser does not support Passkeys",
"register_failed": "Registration failed",
"register_hint": "After clicking confirm, follow your browser's prompts to complete authentication.",
"register_success": "Passkey registered successfully",
"register_title": "Add New Passkey",
"synced": "Synced across devices",
"title": "Passkey Settings"
},
"notify": {
"copy_failed": "Your browser does not support Clipboard API!",
"copy_success": "Copy Success!",
@@ -120,7 +161,17 @@
"update_success": "Update Success!"
},
"player": {
"hit": "Please set up the media player"
"hit": "Please set up the media player",
"empty": {
"title": "Media player not configured",
"subtitle": "Connect your media server to stream directly from here",
"step1_title": "Open Config",
"step1_desc": "Navigate to the Config page and find the Media Player section.",
"step2_title": "Set Player URL",
"step2_desc": "Enter the URL of your media server (Jellyfin, Emby, Plex, etc.).",
"step3_title": "Start Watching",
"step3_desc": "Your media player will be embedded here for easy access."
}
},
"rss": {
"delete": "Delete",
@@ -132,6 +183,37 @@
"title": "RSS Item",
"url": "Url"
},
"calendar": {
"title": "Schedule",
"subtitle": "This season's broadcast schedule",
"days": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
},
"days_short": {
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
},
"unknown": "Unknown",
"today": "Today",
"empty": "No anime",
"refresh": "Refresh schedule",
"no_data": "No schedule data available",
"empty_state": {
"title": "No Schedule Yet",
"subtitle": "Add anime from RSS to see your weekly schedule"
}
},
"sidebar": {
"calendar": "Calendar",
"config": "Config",

View File

@@ -70,9 +70,29 @@
}
},
"downloader": {
"hit": "请设置下载器"
"hit": "请设置下载器",
"empty": {
"title": "下载器未配置",
"subtitle": "连接下载客户端以在此管理种子",
"step1_title": "打开设置",
"step1_desc": "前往设置页面,找到下载器设置部分。",
"step2_title": "输入连接信息",
"step2_desc": "设置 qBittorrent 的地址、用户名和密码。",
"step3_title": "访问下载器",
"step3_desc": "配置完成后,下载器界面将直接嵌入此处。"
}
},
"homepage": {
"empty": {
"title": "暂无订阅",
"subtitle": "添加你的第一个 RSS 订阅开始使用",
"step1_title": "添加 RSS 订阅",
"step1_desc": "点击顶部栏的「添加」按钮,粘贴来自番剧源的 RSS 链接。",
"step2_title": "配置下载器",
"step2_desc": "前往设置页面,配置你的下载器(如 qBittorrent连接信息。",
"step3_title": "坐享其成",
"step3_desc": "AutoBangumi 将自动下载并重命名新剧集。"
},
"rule": {
"apply": "应用",
"delete": "删除",
@@ -103,10 +123,31 @@
},
"login": {
"login_btn": "登录",
"passkey_btn": "通行密钥",
"password": "密码",
"title": "登录",
"username": "用户名"
},
"passkey": {
"add_new": "添加 Passkey",
"created_at": "创建于",
"delete_confirm": "确定删除此 Passkey",
"delete_failed": "删除失败",
"delete_success": "Passkey 已删除",
"device_name": "设备名称",
"device_name_placeholder": "例如iPhone 15, MacBook Pro",
"last_used": "最后使用",
"loading": "加载中...",
"login_failed": "Passkey 登录失败",
"no_passkeys": "尚未注册任何 Passkey",
"not_supported": "您的浏览器不支持 Passkey",
"register_failed": "注册失败",
"register_hint": "点击确认后,请按照浏览器提示完成身份验证。",
"register_success": "Passkey 注册成功",
"register_title": "添加新的 Passkey",
"synced": "已同步到多设备",
"title": "Passkey 设置"
},
"notify": {
"copy_failed": "您的浏览器不支持剪贴板操作!",
"copy_success": "复制成功!",
@@ -120,7 +161,17 @@
"update_success": "更新成功!"
},
"player": {
"hit": "请设置媒体播放器地址"
"hit": "请设置媒体播放器地址",
"empty": {
"title": "播放器未配置",
"subtitle": "连接媒体服务器以在此直接播放",
"step1_title": "打开设置",
"step1_desc": "前往设置页面,找到播放器设置部分。",
"step2_title": "设置播放器地址",
"step2_desc": "输入媒体服务器的 URLJellyfin、Emby、Plex 等)。",
"step3_title": "开始观看",
"step3_desc": "播放器将嵌入此处,方便随时访问。"
}
},
"rss": {
"delete": "删除",
@@ -132,6 +183,37 @@
"title": "RSS 条目",
"url": "链接"
},
"calendar": {
"title": "放送表",
"subtitle": "本季度放送时间表",
"days": {
"mon": "周一",
"tue": "周二",
"wed": "周三",
"thu": "周四",
"fri": "周五",
"sat": "周六",
"sun": "周日"
},
"days_short": {
"mon": "一",
"tue": "二",
"wed": "三",
"thu": "四",
"fri": "五",
"sat": "六",
"sun": "日"
},
"unknown": "未知",
"today": "今天",
"empty": "今日无番",
"refresh": "刷新放送表",
"no_data": "暂无放送数据",
"empty_state": {
"title": "暂无放送表",
"subtitle": "从 RSS 添加番剧后即可查看每周放送时间"
}
},
"sidebar": {
"calendar": "番剧日历",
"config": "设置",

View File

@@ -4,35 +4,44 @@ definePage({
redirect: '/bangumi',
});
const title = computed(() => useRoute().name);
const { editRule } = storeToRefs(useBangumiStore());
const { updateRule, enableRule, ruleManage } = useBangumiStore();
</script>
<template>
<div class="layout-container">
<a href="#main-content" class="skip-link">Skip to content</a>
<ab-topbar />
<main class="layout-main">
<ab-sidebar />
<div class="layout-content">
<ab-page-title :title="title"></ab-page-title>
<div id="main-content" class="layout-content">
<ab-page-title :title="$route.name"></ab-page-title>
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
<transition name="page" mode="out-in">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</transition>
</RouterView>
</div>
</main>
<ab-edit-rule
v-model:show="editRule.show"
v-model:rule="editRule.item"
@enable="(id) => enableRule(id)"
@delete-file="(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)"
@apply="(rule) => updateRule(rule.id, rule)"
/>
</div>
</template>
<style lang="scss" scoped>
.layout-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
@@ -42,7 +51,8 @@ const title = computed(() => useRoute().name);
display: flex;
flex-direction: column;
background: #f0f0f0;
background: var(--color-bg);
transition: background-color var(--transition-normal);
@include forPC {
min-width: 1024px;
@@ -51,15 +61,16 @@ const title = computed(() => useRoute().name);
@include forMobile {
overflow: hidden;
height: 100vh;
}
}
.layout-main {
display: flex;
gap: 20px;
gap: var(--layout-gap);
overflow: hidden;
height: calc(100vh - 2 * var(--layout-padding) - 60px - var(--layout-gap));
height: calc(100vh - 2 * var(--layout-padding) - 56px - var(--layout-gap));
@include forMobile {
flex-direction: column-reverse;
@@ -74,5 +85,24 @@ const title = computed(() => useRoute().name);
display: flex;
flex-direction: column;
flex: 1;
gap: var(--layout-gap);
}
.skip-link {
position: absolute;
top: -100%;
left: 16px;
z-index: 100;
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-sm);
font-size: 14px;
text-decoration: none;
transition: top var(--transition-fast);
&:focus {
top: 16px;
}
}
</style>

View File

@@ -3,9 +3,8 @@ definePage({
name: 'Bangumi List',
});
const { bangumi, editRule } = storeToRefs(useBangumiStore());
const { getAll, updateRule, enableRule, openEditPopup, ruleManage } =
useBangumiStore();
const { bangumi } = storeToRefs(useBangumiStore());
const { getAll, openEditPopup } = useBangumiStore();
const { isMobile } = useBreakpointQuery();
@@ -15,48 +14,196 @@ onActivated(() => {
</script>
<template>
<div overflow-auto pr-10 mt-12 flex-grow>
<div>
<transition-group
name="bangumi"
tag="div"
gap="10"
pc:gap="20"
:class="[
{ 'justify-center': isMobile },
isMobile ? 'grid grid-cols-3' : 'flex flex-wrap',
]"
>
<ab-bangumi-card
v-for="i in bangumi"
:key="i.id"
:class="[i.deleted && 'grayscale']"
:bangumi="i"
type="primary"
@click="() => openEditPopup(i)"
></ab-bangumi-card>
</transition-group>
<div class="page-bangumi">
<!-- Empty state guide -->
<div v-if="!bangumi || bangumi.length === 0" class="empty-guide">
<div class="empty-guide-header anim-fade-in">
<div class="empty-guide-title">{{ $t('homepage.empty.title') }}</div>
<div class="empty-guide-subtitle">{{ $t('homepage.empty.subtitle') }}</div>
</div>
<ab-edit-rule
v-model:show="editRule.show"
v-model:rule="editRule.item"
@enable="(id) => enableRule(id)"
@delete-file="
(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)
"
@apply="(rule) => updateRule(rule.id, rule)"
></ab-edit-rule>
<div class="empty-guide-steps">
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
<div class="empty-guide-step-number">1</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('homepage.empty.step1_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('homepage.empty.step1_desc') }}</div>
</div>
</div>
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
<div class="empty-guide-step-number">2</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('homepage.empty.step2_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('homepage.empty.step2_desc') }}</div>
</div>
</div>
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
<div class="empty-guide-step-number">3</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('homepage.empty.step3_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('homepage.empty.step3_desc') }}</div>
</div>
</div>
</div>
</div>
<!-- Bangumi grid -->
<transition-group
v-else
name="bangumi"
tag="div"
class="bangumi-grid"
:class="{ 'bangumi-grid--centered': isMobile }"
>
<ab-bangumi-card
v-for="i in bangumi"
:key="i.id"
:class="[i.deleted && 'grayscale']"
:bangumi="i"
type="primary"
@click="() => openEditPopup(i)"
></ab-bangumi-card>
</transition-group>
</div>
</template>
<style lang="scss" scoped>
.page-bangumi {
overflow: auto;
flex-grow: 1;
}
.bangumi-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
&--centered {
justify-content: center;
}
}
.empty-guide {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 24px;
}
.empty-guide-header {
text-align: center;
margin-bottom: 32px;
}
.empty-guide-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.empty-guide-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
}
.empty-guide-steps {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
width: 100%;
}
.empty-guide-step {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.empty-guide-step-number {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.empty-guide-step-content {
flex: 1;
min-width: 0;
}
.empty-guide-step-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 4px;
}
.empty-guide-step-desc {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.anim-fade-in {
animation: fadeIn 0.5s ease both;
}
.anim-slide-up {
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--delay, 0s);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<style>
.bangumi-enter-active,
.bangumi-leave-active {
transition: all 0.5s ease;
transition: opacity var(--transition-normal), transform var(--transition-normal);
}
.bangumi-enter-from,
.bangumi-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>

View File

@@ -1,9 +1,638 @@
<script lang="ts" setup>
import { ErrorPicture, Refresh } from '@icon-park/vue-next';
import type { BangumiRule } from '#/bangumi';
definePage({
name: 'Calendar',
});
const { t } = useMyI18n();
const { bangumi } = storeToRefs(useBangumiStore());
const { getAll, openEditPopup } = useBangumiStore();
const { isMobile } = useBreakpointQuery();
const refreshing = ref(false);
async function refreshCalendar() {
refreshing.value = true;
try {
await apiBangumi.refreshCalendar();
await getAll();
} finally {
refreshing.value = false;
}
}
onActivated(() => {
getAll();
refreshCalendar();
});
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
const todayIndex = computed(() => {
// JS getDay(): 0=Sun, 1=Mon, ..., 6=Sat
// We want: 0=Mon, 1=Tue, ..., 6=Sun
const jsDay = new Date().getDay();
return jsDay === 0 ? 6 : jsDay - 1;
});
const bangumiByDay = computed(() => {
const groups: Record<string, BangumiRule[]> = {};
DAY_KEYS.forEach((key) => (groups[key] = []));
groups['unknown'] = [];
bangumi.value?.forEach((item) => {
if (item.deleted) return;
const weekday = item.air_weekday;
if (weekday != null && weekday >= 0 && weekday <= 6) {
groups[DAY_KEYS[weekday]].push(item);
} else {
groups['unknown'].push(item);
}
});
return groups;
});
const hasBangumi = computed(() => {
return bangumi.value && bangumi.value.some((b) => !b.deleted);
});
function getDayLabel(key: string): string {
if (key === 'unknown') return t('calendar.unknown');
return isMobile.value
? t(`calendar.days.${key}`)
: t(`calendar.days_short.${key}`);
}
function isToday(index: number): boolean {
return index === todayIndex.value;
}
</script>
<template>
<div>null</div>
<div class="page-calendar">
<!-- Header -->
<div class="calendar-header anim-fade-in">
<div class="calendar-header-text">
<h2 class="calendar-title">{{ $t('calendar.title') }}</h2>
<p class="calendar-subtitle">{{ $t('calendar.subtitle') }}</p>
</div>
<button
class="calendar-refresh-btn"
:class="{ 'calendar-refresh-btn--spinning': refreshing }"
:disabled="refreshing"
:title="$t('calendar.refresh')"
@click="refreshCalendar"
>
<Refresh :size="18" />
</button>
</div>
<!-- Empty state -->
<div v-if="!hasBangumi" class="empty-guide">
<div class="empty-guide-header anim-fade-in">
<div class="empty-guide-title">{{ $t('calendar.empty_state.title') }}</div>
<div class="empty-guide-subtitle">{{ $t('calendar.empty_state.subtitle') }}</div>
</div>
</div>
<!-- Desktop: Grid columns -->
<div v-else-if="!isMobile" class="calendar-grid">
<div
v-for="(key, index) in [...DAY_KEYS, 'unknown']"
:key="key"
class="calendar-column anim-slide-up"
:class="{ 'calendar-column--today': key !== 'unknown' && isToday(index) }"
:style="{ '--delay': `${index * 0.05}s` }"
>
<!-- Day header -->
<div
class="calendar-day-header"
:class="{ 'calendar-day-header--today': key !== 'unknown' && isToday(index) }"
>
<span class="calendar-day-label">{{ getDayLabel(key) }}</span>
<span
v-if="key !== 'unknown' && isToday(index)"
class="calendar-today-badge"
>
{{ $t('calendar.today') }}
</span>
</div>
<!-- Anime cards -->
<div class="calendar-column-items">
<div
v-for="item in bangumiByDay[key]"
:key="item.id"
class="calendar-card"
role="button"
tabindex="0"
:aria-label="`Edit ${item.official_title}`"
@click="openEditPopup(item)"
@keydown.enter="openEditPopup(item)"
>
<div class="calendar-card-poster">
<img
v-if="item.poster_link"
:src="item.poster_link"
:alt="item.official_title"
class="calendar-card-img"
/>
<div v-else class="calendar-card-placeholder">
<ErrorPicture theme="outline" size="20" />
</div>
</div>
<div class="calendar-card-info">
<div class="calendar-card-title">{{ item.official_title }}</div>
<div class="calendar-card-meta">
<ab-tag :title="`S${item.season}`" type="primary" />
<ab-tag
v-if="item.group_name"
:title="item.group_name"
type="primary"
/>
</div>
</div>
</div>
<!-- Empty day -->
<div v-if="bangumiByDay[key].length === 0" class="calendar-empty-day">
{{ $t('calendar.empty') }}
</div>
</div>
</div>
</div>
<!-- Mobile: Vertical list -->
<div v-else class="calendar-list">
<template v-for="(key, index) in [...DAY_KEYS, 'unknown']" :key="key">
<div
v-if="bangumiByDay[key].length > 0"
class="calendar-section anim-slide-up"
:style="{ '--delay': `${index * 0.05}s` }"
>
<!-- Day divider -->
<div
class="calendar-section-header"
:class="{ 'calendar-section-header--today': key !== 'unknown' && isToday(index) }"
>
<span class="calendar-section-label">{{ getDayLabel(key) }}</span>
<span
v-if="key !== 'unknown' && isToday(index)"
class="calendar-today-badge calendar-today-badge--small"
>
{{ $t('calendar.today') }}
</span>
</div>
<!-- Anime rows -->
<div class="calendar-section-items">
<div
v-for="item in bangumiByDay[key]"
:key="item.id"
class="calendar-row"
role="button"
tabindex="0"
:aria-label="`Edit ${item.official_title}`"
@click="openEditPopup(item)"
@keydown.enter="openEditPopup(item)"
>
<div class="calendar-row-poster">
<img
v-if="item.poster_link"
:src="item.poster_link"
:alt="item.official_title"
class="calendar-row-img"
/>
<div v-else class="calendar-row-placeholder">
<ErrorPicture theme="outline" size="16" />
</div>
</div>
<div class="calendar-row-info">
<div class="calendar-row-title">{{ item.official_title }}</div>
<div class="calendar-row-meta">
<ab-tag :title="`S${item.season}`" type="primary" />
<ab-tag
v-if="item.group_name"
:title="item.group_name"
type="primary"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- All days empty on mobile -->
<div v-if="!hasBangumi" class="calendar-empty-day calendar-empty-day--mobile">
{{ $t('calendar.no_data') }}
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-calendar {
overflow: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
// Header
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.calendar-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin: 0;
transition: color var(--transition-normal);
}
.calendar-subtitle {
font-size: 13px;
color: var(--color-text-secondary);
margin: 4px 0 0;
transition: color var(--transition-normal);
}
.calendar-refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast),
border-color var(--transition-fast),
background-color var(--transition-fast);
&:hover:not(:disabled) {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-light);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--spinning {
:deep(svg) {
animation: spin 1s linear infinite;
}
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// Desktop grid
.calendar-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
flex: 1;
}
.calendar-column {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
&--today {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary-light);
}
}
.calendar-day-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
&--today {
background: var(--color-primary-light);
}
}
.calendar-day-label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
transition: color var(--transition-normal);
.calendar-day-header--today & {
color: var(--color-primary);
}
}
.calendar-today-badge {
font-size: 11px;
font-weight: 500;
color: var(--color-primary);
background: var(--color-primary-light);
padding: 1px 6px;
border-radius: var(--radius-full);
&--small {
font-size: 10px;
padding: 0 5px;
}
}
.calendar-column-items {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
// Desktop card
.calendar-card {
cursor: pointer;
user-select: none;
border-radius: var(--radius-md);
transition: transform var(--transition-fast),
box-shadow var(--transition-fast);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.calendar-card-poster {
border-radius: var(--radius-sm);
overflow: hidden;
aspect-ratio: 2 / 3;
}
.calendar-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.calendar-card-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-hover);
color: var(--color-text-muted);
transition: background-color var(--transition-normal);
}
.calendar-card-info {
padding: 6px 2px 2px;
}
.calendar-card-title {
font-size: 12px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
transition: color var(--transition-normal);
}
.calendar-card-meta {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
// Empty day
.calendar-empty-day {
font-size: 12px;
color: var(--color-text-muted);
text-align: center;
padding: 12px 4px;
transition: color var(--transition-normal);
&--mobile {
padding: 32px 16px;
font-size: 14px;
}
}
// Mobile list
.calendar-list {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.calendar-section {
margin-bottom: 8px;
}
.calendar-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 6px;
border-bottom: 1px solid var(--color-border);
margin-bottom: 6px;
transition: border-color var(--transition-normal);
&--today {
border-bottom-color: var(--color-primary);
}
}
.calendar-section-label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-muted);
letter-spacing: 0.3px;
transition: color var(--transition-normal);
.calendar-section-header--today & {
color: var(--color-primary);
}
}
.calendar-section-items {
display: flex;
flex-direction: column;
}
.calendar-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-surface-hover);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.calendar-row-poster {
width: 44px;
height: 62px;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.calendar-row-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.calendar-row-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-hover);
color: var(--color-text-muted);
transition: background-color var(--transition-normal);
}
.calendar-row-info {
flex: 1;
min-width: 0;
}
.calendar-row-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
transition: color var(--transition-normal);
}
.calendar-row-meta {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
// Empty state (reuse pattern from bangumi page)
.empty-guide {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 40vh;
padding: 24px;
}
.empty-guide-header {
text-align: center;
}
.empty-guide-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
transition: color var(--transition-normal);
}
.empty-guide-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
transition: color var(--transition-normal);
}
// Animations
.anim-fade-in {
animation: fadeIn 0.5s ease both;
}
.anim-slide-up {
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--delay, 0s);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.anim-fade-in,
.anim-slide-up {
animation: none;
}
.calendar-card {
&:hover {
transform: none;
}
}
}
</style>

View File

@@ -12,46 +12,76 @@ onActivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div h-full>
<div grid="~ pc:cols-2 gap-20" mb-auto>
<div space-y-20>
<config-normal></config-normal>
<config-parser></config-parser>
<config-download></config-download>
<config-manage></config-manage>
</div>
<div space-y-20>
<config-notification></config-notification>
<config-proxy></config-proxy>
<config-player></config-player>
<config-openai></config-openai>
</div>
<div class="page-config">
<div class="config-grid">
<div class="config-col">
<config-normal></config-normal>
<config-parser></config-parser>
<config-download></config-download>
<config-manage></config-manage>
</div>
<div fx-cer justify-end gap-8 mt-20>
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="warn"
@click="getConfig"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="primary"
@click="setConfig"
>
{{ $t('config.apply') }}
</ab-button>
<div class="config-col">
<config-notification></config-notification>
<config-proxy></config-proxy>
<config-player></config-player>
<config-openai></config-openai>
<config-passkey></config-passkey>
</div>
</div>
<div class="config-actions">
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="warn"
@click="getConfig"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="primary"
@click="setConfig"
>
{{ $t('config.apply') }}
</ab-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-config {
overflow: auto;
flex-grow: 1;
}
.config-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: auto;
@media (min-width: 1024px) {
grid-template-columns: 1fr 1fr;
}
}
.config-col {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-actions {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding: 12px 0;
backdrop-filter: blur(8px);
background: color-mix(in srgb, var(--color-background) 80%, transparent);
}
</style>

View File

@@ -24,22 +24,192 @@ onActivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<template v-if="isNull">
<div wh-full f-cer text-h1 text-primary>
<RouterLink to="/config" hover:underline>{{
$t('downloader.hit')
}}</RouterLink>
<div class="page-embed">
<div v-if="isNull" class="empty-guide">
<div class="empty-guide-header anim-fade-in">
<div class="empty-guide-title">{{ $t('downloader.empty.title') }}</div>
<div class="empty-guide-subtitle">{{ $t('downloader.empty.subtitle') }}</div>
</div>
</template>
<div class="empty-guide-steps">
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
<div class="empty-guide-step-number">1</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('downloader.empty.step1_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('downloader.empty.step1_desc') }}</div>
</div>
</div>
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
<div class="empty-guide-step-number">2</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('downloader.empty.step2_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('downloader.empty.step2_desc') }}</div>
</div>
</div>
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
<div class="empty-guide-step-number">3</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('downloader.empty.step3_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('downloader.empty.step3_desc') }}</div>
</div>
</div>
</div>
<RouterLink to="/config" class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
{{ $t('sidebar.config') }}
</RouterLink>
</div>
<iframe
v-else
:src="url"
frameborder="0"
allowfullscreen="true"
wh-full
flex-1
rounded-12
class="embed-frame"
></iframe>
</div>
</template>
<style lang="scss" scoped>
.page-embed {
overflow: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.embed-frame {
width: 100%;
height: 100%;
flex: 1;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.empty-guide {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
}
.empty-guide-header {
text-align: center;
margin-bottom: 32px;
}
.empty-guide-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.empty-guide-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
}
.empty-guide-steps {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
width: 100%;
}
.empty-guide-step {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.empty-guide-step-number {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.empty-guide-step-content {
flex: 1;
min-width: 0;
}
.empty-guide-step-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 4px;
}
.empty-guide-step-desc {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.empty-guide-action {
margin-top: 24px;
padding: 8px 24px;
border-radius: var(--radius-md);
background: var(--color-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
}
.anim-fade-in {
animation: fadeIn 0.5s ease both;
}
.anim-slide-up {
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--delay, 0s);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -31,13 +31,13 @@ const formatLog = computed(() => {
});
function typeColor(type: string) {
const M = {
INFO: '#4e3c94',
WARNING: '#A76E18',
ERROR: '#C70E0E',
DEBUG: '#A0A0A0',
const M: Record<string, string> = {
INFO: 'var(--color-primary)',
WARNING: 'var(--color-warning)',
ERROR: 'var(--color-danger)',
DEBUG: 'var(--color-text-muted)',
};
return M[type];
return M[type] || 'var(--color-text)';
}
const logContainer = ref<HTMLElement | null>(null);
@@ -71,40 +71,27 @@ onDeactivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div flex="~ wrap gap-12">
<ab-container :title="$t('log.title')" w-660 grow>
<div
ref="logContainer"
rounded-10
border="1 solid black"
overflow-auto
p-10
max-h-60vh
min-h-20vh
>
<div min-w-450>
<div class="page-log">
<div class="log-layout">
<ab-container :title="$t('log.title')" class="log-main">
<div ref="logContainer" class="log-viewer">
<div class="log-content">
<template v-for="i in formatLog" :key="i.index">
<div
p="y-10"
leading="1.5em"
border="0 b-1 solid"
last:border-b-0
flex="~ items-center gap-20"
class="log-entry"
:style="{ color: typeColor(i.type) }"
>
<div flex="~ col items-center gap-10" whitespace-nowrap>
<div text="center">{{ i.type }}</div>
<div>{{ i.date }}</div>
<div class="log-meta">
<div class="log-type">{{ i.type }}</div>
<div class="log-date">{{ i.date }}</div>
</div>
<div flex-1 break-all>{{ i.content }}</div>
<div class="log-message">{{ i.content }}</div>
</div>
</template>
</div>
</div>
<div flex="~ justify-end gap-x-10" mt-12>
<div class="log-actions">
<ab-button size="small" @click="getLog">
{{ $t('log.update_now') }}
</ab-button>
@@ -119,9 +106,9 @@ onDeactivated(() => {
</div>
</ab-container>
<div grow w-500 space-y-20>
<div class="log-sidebar">
<ab-container :title="$t('log.contact_info')">
<div space-y-12>
<div class="contact-list">
<ab-label label="Github">
<ab-button
size="small"
@@ -142,7 +129,7 @@ onDeactivated(() => {
</ab-button>
</ab-label>
<div line></div>
<div class="divider"></div>
<ab-label label="X">
<ab-button
@@ -167,21 +154,17 @@ onDeactivated(() => {
</ab-container>
<ab-container :title="$t('log.bug_repo')">
<div space-y-12>
<div class="bug-section">
<ab-button
mx-auto
text-16
w-300
h-46
rounded-10
class="issues-btn"
link="https://github.com/EstrellaXD/Auto_Bangumi/issues"
>
Github Issues
</ab-button>
<div line></div>
<div class="divider"></div>
<div text="center primary h3">
<div class="version-info">
<span>Version: </span>
<span>{{ version }}</span>
</div>
@@ -191,3 +174,128 @@ onDeactivated(() => {
</div>
</div>
</template>
<style lang="scss" scoped>
.page-log {
overflow: auto;
flex-grow: 1;
}
.log-layout {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
align-items: start;
@media (min-width: 1024px) {
grid-template-columns: 3fr 2fr;
}
}
.log-main {
min-width: 0;
}
.log-viewer {
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
overflow: auto;
padding: 10px;
max-height: 60vh;
transition: border-color var(--transition-normal);
}
.log-content {
min-width: 0;
}
.log-entry {
padding: 10px 0;
line-height: 1.5;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: flex-start;
gap: 12px;
@media (min-width: 1024px) {
align-items: center;
gap: 20px;
}
&:last-child {
border-bottom: none;
}
}
.log-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.log-type {
text-align: center;
font-weight: 500;
font-size: 12px;
}
.log-date {
font-size: 11px;
opacity: 0.8;
}
.log-message {
flex: 1;
word-break: break-all;
font-size: 13px;
}
.log-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
.log-sidebar {
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.contact-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.divider {
width: 100%;
height: 1px;
background: var(--color-border);
}
.bug-section {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.issues-btn {
width: 100%;
max-width: 300px;
height: 46px;
font-size: 16px;
border-radius: var(--radius-md);
}
.version-info {
text-align: center;
color: var(--color-primary);
font-size: 16px;
}
</style>

View File

@@ -7,22 +7,192 @@ const { url } = storeToRefs(usePlayerStore());
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<template v-if="url.length === 0">
<div wh-full f-cer text-h1 text-primary>
<RouterLink to="/config" hover:underline>{{
$t('player.hit')
}}</RouterLink>
<div class="page-embed">
<div v-if="url === ''" class="empty-guide">
<div class="empty-guide-header anim-fade-in">
<div class="empty-guide-title">{{ $t('player.empty.title') }}</div>
<div class="empty-guide-subtitle">{{ $t('player.empty.subtitle') }}</div>
</div>
</template>
<div class="empty-guide-steps">
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
<div class="empty-guide-step-number">1</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('player.empty.step1_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('player.empty.step1_desc') }}</div>
</div>
</div>
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
<div class="empty-guide-step-number">2</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('player.empty.step2_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('player.empty.step2_desc') }}</div>
</div>
</div>
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
<div class="empty-guide-step-number">3</div>
<div class="empty-guide-step-content">
<div class="empty-guide-step-title">{{ $t('player.empty.step3_title') }}</div>
<div class="empty-guide-step-desc">{{ $t('player.empty.step3_desc') }}</div>
</div>
</div>
</div>
<RouterLink to="/config" class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
{{ $t('sidebar.config') }}
</RouterLink>
</div>
<iframe
v-else
:src="url"
frameborder="0"
allowfullscreen="true"
wh-full
flex-1
rounded-12
class="embed-frame"
></iframe>
</div>
</template>
<style lang="scss" scoped>
.page-embed {
overflow: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.embed-frame {
width: 100%;
height: 100%;
flex: 1;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.empty-guide {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
}
.empty-guide-header {
text-align: center;
margin-bottom: 32px;
}
.empty-guide-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.empty-guide-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
}
.empty-guide-steps {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
width: 100%;
}
.empty-guide-step {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.empty-guide-step-number {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.empty-guide-step-content {
flex: 1;
min-width: 0;
}
.empty-guide-step-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 4px;
}
.empty-guide-step-desc {
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.4;
}
.empty-guide-action {
margin-top: 24px;
padding: 8px 24px;
border-radius: var(--radius-md);
background: var(--color-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
}
.anim-fade-in {
animation: fadeIn 0.5s ease both;
}
.anim-slide-up {
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--delay, 0s);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -74,7 +74,7 @@ const RSSTableOptions = computed(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div class="page-rss">
<ab-container :title="$t('rss.title')">
<NDataTable
v-bind="RSSTableOptions"
@@ -82,13 +82,13 @@ const RSSTableOptions = computed(() => {
></NDataTable>
<div v-if="selectedRSS.length > 0">
<div line my-12></div>
<div flex="~ justify-end gap-x-10">
<div class="divider"></div>
<div class="rss-actions">
<ab-button @click="enableSelected">{{ $t('rss.enable') }}</ab-button>
<ab-button @click="disableSelected">{{
$t('rss.disable')
}}</ab-button>
<ab-button class="type-warn" @click="deleteSelected">{{
<ab-button type="warn" @click="deleteSelected">{{
$t('rss.delete')
}}</ab-button>
</div>
@@ -96,3 +96,23 @@ const RSSTableOptions = computed(() => {
</ab-container>
</div>
</template>
<style lang="scss" scoped>
.page-rss {
overflow: auto;
flex-grow: 1;
}
.divider {
width: 100%;
height: 1px;
background: var(--color-border);
margin: 12px 0;
}
.rss-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -1,21 +1,42 @@
<script lang="ts" setup>
import { Fingerprint } from '@icon-park/vue-next';
definePage({
name: 'Login',
});
const { user, login } = useAuth();
const { isSupported, loginWithPasskey } = usePasskey();
const isPasskeyLoading = ref(false);
async function handlePasskeyLogin() {
if (!user.username) {
const message = useMessage();
const { t } = useMyI18n();
message.warning(t('notify.please_enter', [t('login.username')]));
return;
}
isPasskeyLoading.value = true;
try {
await loginWithPasskey(user.username);
} finally {
isPasskeyLoading.value = false;
}
}
</script>
<template>
<div wh-screen f-cer bg-page>
<ab-container :title="$t('login.title')" w-365 max-w="90%">
<div space-y-16>
<div class="page-login">
<ab-container :title="$t('login.title')" class="login-card">
<div class="login-form">
<ab-label :label="$t('login.username')">
<input
v-model="user.username"
type="text"
placeholder="username"
ab-input
class="login-input"
/>
</ab-label>
@@ -24,14 +45,25 @@ const { user, login } = useAuth();
v-model="user.password"
type="password"
placeholder="password"
ab-input
class="login-input"
@keyup.enter="login"
/>
</ab-label>
<div line></div>
<div class="divider"></div>
<div flex="~ justify-end">
<div class="login-actions">
<ab-button
v-if="isSupported"
size="small"
type="secondary"
:disabled="isPasskeyLoading"
@click="handlePasskeyLogin"
>
<Fingerprint size="16" />
{{ $t('login.passkey_btn') }}
</ab-button>
<div v-else></div>
<ab-button size="small" @click="login">
{{ $t('login.login_btn') }}
</ab-button>
@@ -40,3 +72,65 @@ const { user, login } = useAuth();
</ab-container>
</div>
</template>
<style lang="scss" scoped>
.page-login {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
}
.login-card {
width: 365px;
max-width: 90%;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-input {
outline: none;
min-width: 0;
width: 200px;
height: 28px;
padding: 0 12px;
font-size: 12px;
text-align: right;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
&:hover {
border-color: var(--color-primary);
}
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
}
&::placeholder {
color: var(--color-text-muted);
}
}
.divider {
width: 100%;
height: 1px;
background: var(--color-border);
}
.login-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,180 @@
import { apiPasskey } from '@/api/passkey';
/**
* WebAuthn 浏览器 API 封装
* 处理 Base64URL 编码和浏览器兼容性
*/
// ============ 工具函数 ============
function base64UrlToBuffer(base64url: string): ArrayBuffer {
// 补齐 padding
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buffer;
}
function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// ============ 注册流程 ============
/**
* 注册新的 Passkey
* @param deviceName 设备名称(用户输入)
*/
export async function registerPasskey(deviceName: string): Promise<void> {
// 1. 获取注册选项
const options = await apiPasskey.getRegistrationOptions();
// 2. 转换选项为浏览器 API 格式
const createOptions: PublicKeyCredentialCreationOptions = {
challenge: base64UrlToBuffer(options.challenge),
rp: options.rp,
user: {
id: base64UrlToBuffer(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams.map((p) => ({
type: p.type as PublicKeyCredentialType,
alg: p.alg,
})),
timeout: options.timeout || 60000,
excludeCredentials: options.excludeCredentials?.map((cred) => ({
type: cred.type as PublicKeyCredentialType,
id: base64UrlToBuffer(cred.id),
transports: cred.transports as AuthenticatorTransport[],
})),
authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,
};
// 3. 调用浏览器 WebAuthn API
let credential: PublicKeyCredential;
try {
const result = await navigator.credentials.create({
publicKey: createOptions,
});
if (!result) {
throw new Error('No credential returned');
}
credential = result as PublicKeyCredential;
} catch (e: unknown) {
if (e instanceof DOMException) {
if (e.name === 'NotAllowedError') {
throw new Error('Authentication was cancelled or timed out');
}
if (e.name === 'SecurityError') {
throw new Error('WebAuthn requires a secure context (HTTPS or localhost)');
}
throw new Error(`Browser rejected the request: ${e.message}`);
}
throw e;
}
// 4. 序列化 credential 为 JSON
const response = credential.response as AuthenticatorAttestationResponse;
const attestationResponse = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
},
};
// 5. 提交到后端验证
await apiPasskey.verifyRegistration({
name: deviceName,
attestation_response: attestationResponse,
});
}
// ============ 认证流程 ============
/**
* 使用 Passkey 登录
* @param username 用户名
*/
export async function loginWithPasskey(username: string): Promise<void> {
// 1. 获取认证选项
const options = await apiPasskey.getLoginOptions({ username });
// 2. 转换选项
const getOptions: PublicKeyCredentialRequestOptions = {
challenge: base64UrlToBuffer(options.challenge),
timeout: options.timeout || 60000,
rpId: options.rpId,
allowCredentials: options.allowCredentials?.map((cred) => ({
type: cred.type as PublicKeyCredentialType,
id: base64UrlToBuffer(cred.id),
transports: cred.transports as AuthenticatorTransport[],
})),
userVerification: options.userVerification as UserVerificationRequirement,
};
// 3. 调用浏览器 API
const credential = (await navigator.credentials.get({
publicKey: getOptions,
})) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to get credential');
}
// 4. 序列化响应
const response = credential.response as AuthenticatorAssertionResponse;
const assertionResponse = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle
? bufferToBase64Url(response.userHandle)
: null,
},
};
// 5. 提交到后端验证并登录
await apiPasskey.loginWithPasskey({
username,
credential: assertionResponse,
});
}
// ============ 浏览器支持检测 ============
export function isWebAuthnSupported(): boolean {
return !!(
window.PublicKeyCredential &&
navigator.credentials &&
navigator.credentials.create
);
}
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
if (!isWebAuthnSupported()) return false;
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
return false;
}
}

View File

@@ -1,20 +1,36 @@
// Base styles
html {
color-scheme: light;
&.dark {
color-scheme: dark;
}
}
body {
font-family: var(--font-family);
color: var(--color-text);
background-color: var(--color-bg);
transition: color var(--transition-normal), background-color var(--transition-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Scrollbar
::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
}
/* 滚动槽--外层轨道 */
::-webkit-scrollbar-track {
background: var(--scrollbar-color);
}
/* 内层轨道(不包含滚动块部分) */
/* 透明度设置为全透明,使得滚动条背景色为网页颜色 */
::-webkit-scrollbar-track-piece {
opacity: 0;
}
/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
border-radius: calc(var(--scrollbar-size) / 2);
background: var(--scrollbar-thumb-color);
@@ -24,18 +40,36 @@
}
}
/* 滚动条按钮 */
::-webkit-scrollbar-button {
display: none;
}
/* 横向滚动条和纵向滚动条相交处尖角的颜色 */
::-webkit-scrollbar-corner {
background-color: transparent;
}
// Remove number input spinners
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// Focus visible ring
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}

View File

@@ -14,7 +14,7 @@ $min-pc: 1024px;
@mixin bg-mouse-event($normal, $hover, $active) {
background: $normal;
transition: background 0.3s;
transition: background-color var(--transition-normal);
&:hover {
background: $hover;

View File

@@ -1,9 +1,9 @@
// transition
// Transitions
.fade {
&-enter-active,
&-leave-active {
transition: opacity 0.2s ease;
transition: opacity var(--transition-normal);
}
&-enter-from,
@@ -13,14 +13,71 @@
}
}
// transition-group
.fade-list-enter-active,
.fade-list-leave-active {
transition: all 0.3s ease;
transition: all var(--transition-slow);
}
.fade-list-enter-from,
.fade-list-leave-to {
opacity: 0;
}
// Slide transitions for sidebar/panels
.slide {
&-enter-active,
&-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
&-enter-from {
transform: translateX(-8px);
opacity: 0;
}
&-leave-to {
transform: translateX(8px);
opacity: 0;
}
}
// Page route transition
.page {
&-enter-active {
transition: opacity var(--transition-normal), transform var(--transition-normal);
}
&-leave-active {
transition: opacity 100ms ease-in;
}
&-enter-from {
opacity: 0;
transform: translateY(6px);
}
&-leave-to {
opacity: 0;
}
}
// Scale-fade for dropdowns/menus
.dropdown {
&-enter-active {
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
&-leave-active {
transition: opacity 100ms ease-in, transform 100ms ease-in;
}
&-enter-from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
&-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
}

View File

@@ -1,16 +1,92 @@
$scrollbar-color: #372a87;
// Design System Variables
// Light theme (default) + Dark theme (.dark class on html)
:root {
// --- Colors ---
--color-primary: #6C4AB6;
--color-primary-hover: #563A92;
--color-primary-light: #E8DEF8;
--color-accent: #F97316;
--color-success: #22C55E;
--color-danger: #EF4444;
--color-warning: #F59E0B;
--color-bg: #FAFAFA;
--color-surface: #FFFFFF;
--color-surface-hover: #F5F5F5;
--color-text: #1E293B;
--color-text-secondary: #64748B;
--color-text-muted: #94A3B8;
--color-border: #E2E8F0;
--color-border-hover: #CBD5E1;
// --- Shadows ---
--shadow-color: rgba(0, 0, 0, 0.08);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
// --- Radius ---
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
// --- Transitions ---
--transition-fast: 150ms ease-out;
--transition-normal: 200ms ease-out;
--transition-slow: 300ms ease-out;
// --- Scrollbar ---
--scrollbar-size: 6px;
--scrollbar-color: transparent;
--scrollbar-thumb-color: #{rgba($scrollbar-color, 0.5)};
--scrollbar-thumb-hover-color: #{rgba($scrollbar-color, 1)};
--scrollbar-thumb-color: rgba(108, 74, 182, 0.3);
--scrollbar-thumb-hover-color: rgba(108, 74, 182, 0.6);
// --- Layout ---
--layout-padding: 16px;
--layout-gap: 12px;
// --- Typography ---
--font-family: 'Inter', -apple-system, 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
@include forMobile {
--layout-padding: 10px;
--layout-padding: 12px;
--layout-gap: 10px;
}
}
// Dark theme
.dark {
--color-primary: #8B6CC7;
--color-primary-hover: #A78BDB;
--color-primary-light: #2D2250;
--color-accent: #FB923C;
--color-success: #4ADE80;
--color-danger: #F87171;
--color-warning: #FBBF24;
--color-bg: #0F172A;
--color-surface: #1E293B;
--color-surface-hover: #334155;
--color-text: #F1F5F9;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-border: #334155;
--color-border-hover: #475569;
// --- Shadows (darker for dark mode) ---
--shadow-color: rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
// --- Scrollbar ---
--scrollbar-thumb-color: rgba(139, 108, 199, 0.3);
--scrollbar-thumb-hover-color: rgba(139, 108, 199, 0.6);
}

View File

@@ -21,6 +21,7 @@ export interface BangumiRule {
subtitle: string;
title_raw: string;
year: string | null;
air_weekday: number | null; // 0=Mon, 1=Tue, ..., 6=Sun, null=Unknown
}
export interface BangumiAPI extends Omit<BangumiRule, 'filter' | 'rss_link'> {
@@ -55,4 +56,5 @@ export const ruleTemplate: BangumiRule = {
subtitle: '',
title_raw: '',
year: null,
air_weekday: null,
};

View File

@@ -11,6 +11,7 @@ declare global {
const apiConfig: typeof import('../../src/api/config')['apiConfig']
const apiDownload: typeof import('../../src/api/download')['apiDownload']
const apiLog: typeof import('../../src/api/log')['apiLog']
const apiPasskey: typeof import('../../src/api/passkey')['apiPasskey']
const apiProgram: typeof import('../../src/api/program')['apiProgram']
const apiRSS: typeof import('../../src/api/rss')['apiRSS']
const apiSearch: typeof import('../../src/api/search')['apiSearch']
@@ -22,6 +23,7 @@ declare global {
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
@@ -87,14 +89,21 @@ declare global {
const useAuth: typeof import('../../src/hooks/useAuth')['useAuth']
const useBangumiStore: typeof import('../../src/store/bangumi')['useBangumiStore']
const useBreakpointQuery: typeof import('../../src/hooks/useBreakpointQuery')['useBreakpointQuery']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useConfigStore: typeof import('../../src/store/config')['useConfigStore']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']
const useI18n: typeof import('vue-i18n')['useI18n']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useLogStore: typeof import('../../src/store/log')['useLogStore']
const useMessage: typeof import('../../src/hooks/useMessage')['useMessage']
const useMyI18n: typeof import('../../src/hooks/useMyI18n')['useMyI18n']
const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const useProgramStore: typeof import('../../src/store/program')['useProgramStore']
const useRSSStore: typeof import('../../src/store/rss')['useRSSStore']
const useRoute: typeof import('vue-router/auto')['useRoute']

View File

@@ -40,6 +40,7 @@ declare module '@vue/runtime-core' {
ConfigNotification: typeof import('./../../src/components/setting/config-notification.vue')['default']
ConfigOpenai: typeof import('./../../src/components/setting/config-openai.vue')['default']
ConfigParser: typeof import('./../../src/components/setting/config-parser.vue')['default']
ConfigPasskey: typeof import('./../../src/components/setting/config-passkey.vue')['default']
ConfigPlayer: typeof import('./../../src/components/setting/config-player.vue')['default']
ConfigProxy: typeof import('./../../src/components/setting/config-proxy.vue')['default']
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']

70
webui/types/passkey.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Passkey 类型定义
*/
// Passkey 列表项
export interface PasskeyItem {
id: number;
name: string;
created_at: string;
last_used_at: string | null;
backup_eligible: boolean;
aaguid: string | null;
}
// 注册选项(从后端返回)
export interface RegistrationOptions {
challenge: string;
rp: { name: string; id: string };
user: {
id: string;
name: string;
displayName: string;
};
pubKeyCredParams: Array<{ type: string; alg: number }>;
timeout?: number;
excludeCredentials?: Array<{
type: string;
id: string;
transports?: string[];
}>;
authenticatorSelection?: {
residentKey?: string;
userVerification?: string;
};
}
// 认证选项
export interface AuthenticationOptions {
challenge: string;
timeout?: number;
rpId?: string;
allowCredentials?: Array<{
type: string;
id: string;
transports?: string[];
}>;
userVerification?: string;
}
// 注册请求
export interface PasskeyCreateRequest {
name: string;
attestation_response: unknown;
}
// 删除请求
export interface PasskeyDeleteRequest {
passkey_id: number;
}
// 认证开始请求
export interface PasskeyAuthStartRequest {
username: string;
}
// 认证完成请求
export interface PasskeyAuthFinishRequest {
username: string;
credential: unknown;
}

View File

@@ -8,7 +8,9 @@ import presetRemToPx from '@unocss/preset-rem-to-px';
export default defineConfig({
presets: [
presetUno(),
presetUno({
dark: 'class',
}),
presetRemToPx({
baseFontSize: 4,
}),
@@ -32,38 +34,58 @@ export default defineConfig({
pc: '1024px',
},
colors: {
primary: '#493475',
running: '#A3D491',
stopped: '#DF7F91',
page: '#F0F0F0',
// Semantic colors via CSS variables (support light/dark)
primary: 'var(--color-primary)',
'primary-hover': 'var(--color-primary-hover)',
'primary-light': 'var(--color-primary-light)',
accent: 'var(--color-accent)',
success: 'var(--color-success)',
danger: 'var(--color-danger)',
warning: 'var(--color-warning)',
surface: 'var(--color-surface)',
'surface-hover': 'var(--color-surface-hover)',
'text-primary': 'var(--color-text)',
'text-secondary': 'var(--color-text-secondary)',
'text-muted': 'var(--color-text-muted)',
border: 'var(--color-border)',
'border-hover': 'var(--color-border-hover)',
page: 'var(--color-bg)',
// Legacy aliases (for gradual migration)
running: 'var(--color-success)',
stopped: 'var(--color-danger)',
},
},
rules: [
[
'bg-theme-row',
{
background: 'linear-gradient(90.5deg, #492897 1.53%, #783674 96.48%)',
background: 'linear-gradient(90.5deg, var(--color-primary) 1.53%, var(--color-primary-hover) 96.48%)',
},
],
[
'bg-theme-col',
{
background: 'linear-gradient(180deg, #492897 0%, #783674 100%)',
background: 'linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
},
],
[
'poster-shandow',
{
filter: 'drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.1))',
filter: 'drop-shadow(2px 2px 2px var(--shadow-color, rgba(0, 0, 0, 0.1)))',
},
],
[
'poster-pen-active',
{
background: '#B4ABC6',
'box-shadow': '2px 2px 4px rgba(0, 0, 0, 0.25)',
background: 'var(--color-primary-light)',
'box-shadow': '2px 2px 4px var(--shadow-color, rgba(0, 0, 0, 0.25))',
},
],
// Shadows
['shadow-sm', { 'box-shadow': 'var(--shadow-sm)' }],
['shadow-md', { 'box-shadow': 'var(--shadow-md)' }],
['shadow-lg', { 'box-shadow': 'var(--shadow-lg)' }],
],
shortcuts: [
[/^wh-(.*)$/, ([, t]) => `w-${t} h-${t}`],
@@ -87,17 +109,24 @@ export default defineConfig({
'text-h2': 'text-20',
'text-h3': 'text-16',
'text-main': 'text-12',
'text-body': 'text-14',
'text-sm': 'text-12',
'text-xs': 'text-10',
},
// input
{
'ab-input': `outline-none min-w-0 w-200 h-28
px-12 text-main text-right
rounded-6 shadow-inset
border-1 border-black hover:border-color-[#7A46AE]
rounded-6
border-1 border-border
bg-surface text-text-primary
hover:border-primary
focus:border-primary focus:ring-2 focus:ring-primary/20
transition-colors duration-150
`,
'input-error': 'border-color-[#CA0E0E]',
'input-error': 'border-danger',
'input-reset': 'bg-transparent min-w-0 flex-1 outline-none',
},
@@ -105,12 +134,12 @@ export default defineConfig({
{
'is-btn': 'cursor-pointer select-none',
'btn-click': 'hover:scale-110 active:scale-100',
'is-disabled': 'cursor-not-allowed select-none',
'is-disabled': 'cursor-not-allowed select-none opacity-50',
},
// other
{
line: 'w-full h-1 bg-[#DFE1EF]',
line: 'w-full h-1 bg-border',
},
],
});

View File

@@ -25,7 +25,23 @@ export default defineConfig(({ mode }) => ({
}),
UnoCSS(),
AutoImport({
imports: ['vue', 'vitest', 'pinia', VueRouterAutoImports, 'vue-i18n'],
imports: [
'vue',
'vitest',
'pinia',
{
'@vueuse/core': [
'createSharedComposable',
'useBreakpoints',
'usePreferredDark',
'useClipboard',
'useLocalStorage',
'useIntervalFn',
],
},
VueRouterAutoImports,
'vue-i18n',
],
dts: 'types/dts/auto-imports.d.ts',
dirs: ['src/api', 'src/store', 'src/hooks', 'src/utils'],
}),
@@ -81,7 +97,6 @@ export default defineConfig(({ mode }) => ({
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
additionalData: '@import "./src/style/mixin.scss";',
},
},
@@ -101,8 +116,11 @@ export default defineConfig(({ mode }) => ({
},
server: {
proxy: {
'^/api/.*': 'http://192.168.0.100:7892',
'^/posters/.*': 'http://192.168.0.100:7892',
'^/api/.*': {
target: 'http://localhost:7892',
changeOrigin: false,
},
'^/posters/.*': 'http://localhost:7892',
},
},
}));