mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-14 02:20:53 +08:00
Merge branch 'feature/ui-redesign' into 3.2-dev
# Conflicts: # backend/requirements.txt
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
47
backend/src/dev_server.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
)
|
||||
|
||||
281
backend/src/module/api/passkey.py
Normal file
281
backend/src/module/api/passkey.py
Normal 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))
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
78
backend/src/module/database/passkey.py
Normal file
78
backend/src/module/database/passkey.py
Normal 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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
75
backend/src/module/models/passkey.py
Normal file
75
backend/src/module/models/passkey.py
Normal 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
|
||||
88
backend/src/module/parser/analyser/bgm_calendar.py
Normal file
88
backend/src/module/parser/analyser/bgm_calendar.py
Normal 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
|
||||
104
backend/src/module/security/auth_strategy.py
Normal file
104
backend/src/module/security/auth_strategy.py
Normal 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)}",
|
||||
)
|
||||
277
backend/src/module/security/webauthn.py
Normal file
277
backend/src/module/security/webauthn.py
Normal 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]
|
||||
35
backend/src/test_passkey_server.py
Normal file
35
backend/src/test_passkey_server.py
Normal 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
55
docs/changelog/3.2.md
Normal 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 依赖
|
||||
- 升级前端依赖
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
88
webui/src/api/passkey.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
174
webui/src/components/setting/config-passkey.vue
Normal file
174
webui/src/components/setting/config-passkey.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
49
webui/src/hooks/useDarkMode.ts
Normal file
49
webui/src/hooks/useDarkMode.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
101
webui/src/hooks/usePasskey.ts
Normal file
101
webui/src/hooks/usePasskey.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "输入媒体服务器的 URL(Jellyfin、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": "设置",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
180
webui/src/services/webauthn.ts
Normal file
180
webui/src/services/webauthn.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
9
webui/types/dts/auto-imports.d.ts
vendored
9
webui/types/dts/auto-imports.d.ts
vendored
@@ -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']
|
||||
|
||||
1
webui/types/dts/components.d.ts
vendored
1
webui/types/dts/components.d.ts
vendored
@@ -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
70
webui/types/passkey.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user