mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-10 05:58:00 +08:00
Merge branch '3.2-dev-setup-wizard' into 3.2-dev
Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -9,6 +9,7 @@ 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
|
||||
from .setup import router as setup_router
|
||||
|
||||
__all__ = "v1"
|
||||
|
||||
@@ -23,3 +24,4 @@ v1.include_router(config_router)
|
||||
v1.include_router(downloader_router)
|
||||
v1.include_router(rss_router)
|
||||
v1.include_router(search_router)
|
||||
v1.include_router(setup_router)
|
||||
|
||||
299
backend/src/module/api/setup.py
Normal file
299
backend/src/module/api/setup.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from module.conf import VERSION, settings
|
||||
from module.models import Config, ResponseModel
|
||||
from module.network import RequestContent
|
||||
from module.notification.notification import getClient
|
||||
from module.security.jwt import get_password_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/setup", tags=["setup"])
|
||||
|
||||
SENTINEL_PATH = Path("config/.setup_complete")
|
||||
|
||||
|
||||
def _require_setup_needed():
|
||||
"""Guard: raise 403 if setup is already completed."""
|
||||
if SENTINEL_PATH.exists():
|
||||
raise HTTPException(status_code=403, detail="Setup already completed.")
|
||||
if settings.dict() != Config().dict():
|
||||
raise HTTPException(status_code=403, detail="Setup already completed.")
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
need_setup: bool
|
||||
version: str
|
||||
|
||||
|
||||
class TestDownloaderRequest(BaseModel):
|
||||
type: str = Field("qbittorrent")
|
||||
host: str
|
||||
username: str
|
||||
password: str
|
||||
ssl: bool = False
|
||||
|
||||
|
||||
class TestRSSRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class TestNotificationRequest(BaseModel):
|
||||
type: str
|
||||
token: str
|
||||
chat_id: str = ""
|
||||
|
||||
|
||||
class TestResultResponse(BaseModel):
|
||||
success: bool
|
||||
message_en: str
|
||||
message_zh: str
|
||||
title: str | None = None
|
||||
item_count: int | None = None
|
||||
|
||||
|
||||
class SetupCompleteRequest(BaseModel):
|
||||
username: str = Field(..., min_length=4, max_length=20)
|
||||
password: str = Field(..., min_length=8)
|
||||
downloader_type: str = Field("qbittorrent")
|
||||
downloader_host: str
|
||||
downloader_username: str
|
||||
downloader_password: str
|
||||
downloader_path: str = Field("/downloads/Bangumi")
|
||||
downloader_ssl: bool = False
|
||||
rss_url: str = ""
|
||||
rss_name: str = ""
|
||||
notification_enable: bool = False
|
||||
notification_type: str = "telegram"
|
||||
notification_token: str = ""
|
||||
notification_chat_id: str = ""
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
async def get_setup_status():
|
||||
"""Check whether the setup wizard is needed."""
|
||||
need_setup = not SENTINEL_PATH.exists() and settings.dict() == Config().dict()
|
||||
return SetupStatusResponse(need_setup=need_setup, version=VERSION)
|
||||
|
||||
|
||||
@router.post("/test-downloader", response_model=TestResultResponse)
|
||||
async def test_downloader(req: TestDownloaderRequest):
|
||||
"""Test connection to the download client."""
|
||||
_require_setup_needed()
|
||||
|
||||
scheme = "https" if req.ssl else "http"
|
||||
host = req.host if "://" in req.host else f"{scheme}://{req.host}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Check if host is reachable and is qBittorrent
|
||||
resp = await client.get(host)
|
||||
if "qbittorrent" not in resp.text.lower() and "vuetorrent" not in resp.text.lower():
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Host is reachable but does not appear to be qBittorrent.",
|
||||
message_zh="主机可达但似乎不是 qBittorrent。",
|
||||
)
|
||||
|
||||
# Try to authenticate
|
||||
login_url = f"{host}/api/v2/auth/login"
|
||||
login_resp = await client.post(
|
||||
login_url,
|
||||
data={"username": req.username, "password": req.password},
|
||||
)
|
||||
if login_resp.status_code == 200 and "ok" in login_resp.text.lower():
|
||||
return TestResultResponse(
|
||||
success=True,
|
||||
message_en="Connection successful.",
|
||||
message_zh="连接成功。",
|
||||
)
|
||||
elif login_resp.status_code == 403:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Authentication failed: IP is banned by qBittorrent.",
|
||||
message_zh="认证失败:IP 被 qBittorrent 封禁。",
|
||||
)
|
||||
else:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Authentication failed: incorrect username or password.",
|
||||
message_zh="认证失败:用户名或密码错误。",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Connection timed out.",
|
||||
message_zh="连接超时。",
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Cannot connect to the host.",
|
||||
message_zh="无法连接到主机。",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Setup] Downloader test failed: {e}")
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en=f"Connection failed: {e}",
|
||||
message_zh=f"连接失败:{e}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-rss", response_model=TestResultResponse)
|
||||
async def test_rss(req: TestRSSRequest):
|
||||
"""Test an RSS feed URL."""
|
||||
_require_setup_needed()
|
||||
|
||||
try:
|
||||
async with RequestContent() as request:
|
||||
soup = await request.get_xml(req.url)
|
||||
if soup is None:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Failed to fetch or parse the RSS feed.",
|
||||
message_zh="无法获取或解析 RSS 源。",
|
||||
)
|
||||
title = soup.find("./channel/title")
|
||||
title_text = title.text if title is not None else None
|
||||
items = soup.findall("./channel/item")
|
||||
return TestResultResponse(
|
||||
success=True,
|
||||
message_en="RSS feed is valid.",
|
||||
message_zh="RSS 源有效。",
|
||||
title=title_text,
|
||||
item_count=len(items),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Setup] RSS test failed: {e}")
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en=f"Failed to fetch RSS feed: {e}",
|
||||
message_zh=f"获取 RSS 源失败:{e}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-notification", response_model=TestResultResponse)
|
||||
async def test_notification(req: TestNotificationRequest):
|
||||
"""Send a test notification."""
|
||||
_require_setup_needed()
|
||||
|
||||
NotifierClass = getClient(req.type)
|
||||
if NotifierClass is None:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en=f"Unknown notification type: {req.type}",
|
||||
message_zh=f"未知的通知类型:{req.type}",
|
||||
)
|
||||
|
||||
try:
|
||||
notifier = NotifierClass(token=req.token, chat_id=req.chat_id)
|
||||
async with notifier:
|
||||
# Send a simple test message
|
||||
data = {"chat_id": req.chat_id, "text": "AutoBangumi 通知测试成功!"}
|
||||
if req.type.lower() == "telegram":
|
||||
resp = await notifier.post_data(notifier.message_url, data)
|
||||
if resp.status_code == 200:
|
||||
return TestResultResponse(
|
||||
success=True,
|
||||
message_en="Test notification sent successfully.",
|
||||
message_zh="测试通知发送成功。",
|
||||
)
|
||||
else:
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en="Failed to send test notification.",
|
||||
message_zh="测试通知发送失败。",
|
||||
)
|
||||
else:
|
||||
# For other providers, just verify the notifier can be created
|
||||
return TestResultResponse(
|
||||
success=True,
|
||||
message_en="Notification configuration is valid.",
|
||||
message_zh="通知配置有效。",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Setup] Notification test failed: {e}")
|
||||
return TestResultResponse(
|
||||
success=False,
|
||||
message_en=f"Notification test failed: {e}",
|
||||
message_zh=f"通知测试失败:{e}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=ResponseModel)
|
||||
async def complete_setup(req: SetupCompleteRequest):
|
||||
"""Save all wizard configuration and mark setup as complete."""
|
||||
_require_setup_needed()
|
||||
|
||||
try:
|
||||
# 1. Update user credentials
|
||||
from module.database import Database
|
||||
|
||||
with Database() as db:
|
||||
from module.models.user import UserUpdate
|
||||
|
||||
db.user.update_user(
|
||||
"admin",
|
||||
UserUpdate(username=req.username, password=req.password),
|
||||
)
|
||||
|
||||
# 2. Update configuration
|
||||
config_dict = settings.dict()
|
||||
config_dict["downloader"] = {
|
||||
"type": req.downloader_type,
|
||||
"host": req.downloader_host,
|
||||
"username": req.downloader_username,
|
||||
"password": req.downloader_password,
|
||||
"path": req.downloader_path,
|
||||
"ssl": req.downloader_ssl,
|
||||
}
|
||||
if req.notification_enable:
|
||||
config_dict["notification"] = {
|
||||
"enable": True,
|
||||
"type": req.notification_type,
|
||||
"token": req.notification_token,
|
||||
"chat_id": req.notification_chat_id,
|
||||
}
|
||||
|
||||
settings.save(config_dict)
|
||||
# Reload settings in-place
|
||||
config_obj = Config.parse_obj(config_dict)
|
||||
settings.__dict__.update(config_obj.__dict__)
|
||||
|
||||
# 3. Add RSS feed if provided
|
||||
if req.rss_url:
|
||||
from module.rss import RSSEngine
|
||||
|
||||
with RSSEngine() as rss_engine:
|
||||
await rss_engine.add_rss(req.rss_url, name=req.rss_name or None)
|
||||
|
||||
# 4. Create sentinel file
|
||||
SENTINEL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
SENTINEL_PATH.touch()
|
||||
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en="Setup completed successfully.",
|
||||
msg_zh="设置完成。",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Setup] Complete failed: {e}")
|
||||
return ResponseModel(
|
||||
status=False,
|
||||
status_code=500,
|
||||
msg_en=f"Setup failed: {e}",
|
||||
msg_zh=f"设置失败:{e}",
|
||||
)
|
||||
@@ -30,10 +30,9 @@ class Checker:
|
||||
|
||||
@staticmethod
|
||||
def check_first_run() -> bool:
|
||||
if settings.dict() == Config().dict():
|
||||
return True
|
||||
else:
|
||||
if Path("config/.setup_complete").exists():
|
||||
return False
|
||||
return settings.dict() == Config().dict()
|
||||
|
||||
@staticmethod
|
||||
def check_version() -> tuple[bool, int | None]:
|
||||
|
||||
241
backend/src/test/test_setup.py
Normal file
241
backend/src/test/test_setup.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from module.api.setup import SENTINEL_PATH, router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client for the FastAPI app."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_first_run():
|
||||
"""Mock conditions for first run: sentinel doesn't exist, config matches defaults."""
|
||||
with (
|
||||
patch("module.api.setup.SENTINEL_PATH") as mock_sentinel,
|
||||
patch("module.api.setup.settings") as mock_settings,
|
||||
patch("module.api.setup.Config") as mock_config,
|
||||
):
|
||||
mock_sentinel.exists.return_value = False
|
||||
mock_settings.dict.return_value = {"test": "default"}
|
||||
mock_config.return_value.dict.return_value = {"test": "default"}
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_complete():
|
||||
"""Mock conditions for setup already complete: sentinel exists."""
|
||||
with patch("module.api.setup.SENTINEL_PATH") as mock_sentinel:
|
||||
mock_sentinel.exists.return_value = True
|
||||
yield
|
||||
|
||||
|
||||
class TestSetupStatus:
|
||||
def test_status_first_run(self, client, mock_first_run):
|
||||
response = client.get("/api/v1/setup/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["need_setup"] is True
|
||||
assert "version" in data
|
||||
|
||||
def test_status_setup_complete(self, client, mock_setup_complete):
|
||||
response = client.get("/api/v1/setup/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["need_setup"] is False
|
||||
|
||||
def test_status_config_changed(self, client):
|
||||
"""When config differs from defaults, need_setup should be False."""
|
||||
with (
|
||||
patch("module.api.setup.SENTINEL_PATH") as mock_sentinel,
|
||||
patch("module.api.setup.settings") as mock_settings,
|
||||
patch("module.api.setup.Config") as mock_config,
|
||||
):
|
||||
mock_sentinel.exists.return_value = False
|
||||
mock_settings.dict.return_value = {"test": "changed"}
|
||||
mock_config.return_value.dict.return_value = {"test": "default"}
|
||||
response = client.get("/api/v1/setup/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["need_setup"] is False
|
||||
|
||||
|
||||
class TestSetupGuard:
|
||||
def test_test_downloader_blocked_after_setup(self, client, mock_setup_complete):
|
||||
response = client.post(
|
||||
"/api/v1/setup/test-downloader",
|
||||
json={
|
||||
"type": "qbittorrent",
|
||||
"host": "localhost:8080",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"ssl": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_test_rss_blocked_after_setup(self, client, mock_setup_complete):
|
||||
response = client.post(
|
||||
"/api/v1/setup/test-rss",
|
||||
json={"url": "https://example.com/rss"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_test_notification_blocked_after_setup(self, client, mock_setup_complete):
|
||||
response = client.post(
|
||||
"/api/v1/setup/test-notification",
|
||||
json={"type": "telegram", "token": "test", "chat_id": "123"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_complete_blocked_after_setup(self, client, mock_setup_complete):
|
||||
response = client.post(
|
||||
"/api/v1/setup/complete",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"password": "testpassword123",
|
||||
"downloader_type": "qbittorrent",
|
||||
"downloader_host": "localhost:8080",
|
||||
"downloader_username": "admin",
|
||||
"downloader_password": "admin",
|
||||
"downloader_path": "/downloads",
|
||||
"downloader_ssl": False,
|
||||
"rss_url": "",
|
||||
"rss_name": "",
|
||||
"notification_enable": False,
|
||||
"notification_type": "telegram",
|
||||
"notification_token": "",
|
||||
"notification_chat_id": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestTestDownloader:
|
||||
def test_connection_timeout(self, client, mock_first_run):
|
||||
import httpx
|
||||
|
||||
with patch("module.api.setup.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.side_effect = httpx.TimeoutException("timeout")
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_instance
|
||||
)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/setup/test-downloader",
|
||||
json={
|
||||
"type": "qbittorrent",
|
||||
"host": "localhost:8080",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"ssl": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_connection_refused(self, client, mock_first_run):
|
||||
import httpx
|
||||
|
||||
with patch("module.api.setup.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get.side_effect = httpx.ConnectError("refused")
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_instance
|
||||
)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/setup/test-downloader",
|
||||
json={
|
||||
"type": "qbittorrent",
|
||||
"host": "localhost:8080",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"ssl": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestTestRSS:
|
||||
def test_invalid_url(self, client, mock_first_run):
|
||||
with patch("module.api.setup.RequestContent") as mock_rc:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.get_xml = AsyncMock(return_value=None)
|
||||
mock_rc.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
|
||||
mock_rc.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/setup/test-rss",
|
||||
json={"url": "https://invalid.example.com/rss"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestRequestValidation:
|
||||
def test_username_too_short(self, client, mock_first_run):
|
||||
response = client.post(
|
||||
"/api/v1/setup/complete",
|
||||
json={
|
||||
"username": "ab",
|
||||
"password": "testpassword123",
|
||||
"downloader_type": "qbittorrent",
|
||||
"downloader_host": "localhost:8080",
|
||||
"downloader_username": "admin",
|
||||
"downloader_password": "admin",
|
||||
"downloader_path": "/downloads",
|
||||
"downloader_ssl": False,
|
||||
"rss_url": "",
|
||||
"rss_name": "",
|
||||
"notification_enable": False,
|
||||
"notification_type": "telegram",
|
||||
"notification_token": "",
|
||||
"notification_chat_id": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_password_too_short(self, client, mock_first_run):
|
||||
response = client.post(
|
||||
"/api/v1/setup/complete",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"password": "short",
|
||||
"downloader_type": "qbittorrent",
|
||||
"downloader_host": "localhost:8080",
|
||||
"downloader_username": "admin",
|
||||
"downloader_password": "admin",
|
||||
"downloader_path": "/downloads",
|
||||
"downloader_ssl": False,
|
||||
"rss_url": "",
|
||||
"rss_name": "",
|
||||
"notification_enable": False,
|
||||
"notification_type": "telegram",
|
||||
"notification_token": "",
|
||||
"notification_chat_id": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestSentinelPath:
|
||||
def test_sentinel_path_is_in_config_dir(self):
|
||||
assert str(SENTINEL_PATH) == "config/.setup_complete"
|
||||
assert SENTINEL_PATH.parent == Path("config")
|
||||
46
webui/src/api/setup.ts
Normal file
46
webui/src/api/setup.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
SetupCompleteRequest,
|
||||
SetupStatus,
|
||||
TestDownloaderRequest,
|
||||
TestNotificationRequest,
|
||||
TestResult,
|
||||
} from '#/setup';
|
||||
import type { ApiSuccess } from '#/api';
|
||||
|
||||
export const apiSetup = {
|
||||
async getStatus() {
|
||||
const { data } = await axios.get<SetupStatus>('api/v1/setup/status');
|
||||
return data;
|
||||
},
|
||||
|
||||
async testDownloader(config: TestDownloaderRequest) {
|
||||
const { data } = await axios.post<TestResult>(
|
||||
'api/v1/setup/test-downloader',
|
||||
config
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async testRSS(url: string) {
|
||||
const { data } = await axios.post<TestResult>('api/v1/setup/test-rss', {
|
||||
url,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async testNotification(config: TestNotificationRequest) {
|
||||
const { data } = await axios.post<TestResult>(
|
||||
'api/v1/setup/test-notification',
|
||||
config
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async complete(config: SetupCompleteRequest) {
|
||||
const { data } = await axios.post<ApiSuccess>(
|
||||
'api/v1/setup/complete',
|
||||
config
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
70
webui/src/components/setup/wizard-container.vue
Normal file
70
webui/src/components/setup/wizard-container.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}>();
|
||||
|
||||
const { t } = useMyI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wizard-container">
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-bar">
|
||||
<div
|
||||
class="wizard-progress-fill"
|
||||
:style="{ width: `${(currentStep / (totalSteps - 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="wizard-step-indicator">
|
||||
{{ t('setup.nav.step', { current: currentStep + 1, total: totalSteps }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-container {
|
||||
width: 480px;
|
||||
max-width: 92%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.wizard-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wizard-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.wizard-step-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wizard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
133
webui/src/components/setup/wizard-step-account.vue
Normal file
133
webui/src/components/setup/wizard-step-account.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { accountData } = storeToRefs(setupStore);
|
||||
|
||||
const isValid = computed(() => {
|
||||
return (
|
||||
accountData.value.username.length >= 4 &&
|
||||
accountData.value.password.length >= 8 &&
|
||||
accountData.value.password === accountData.value.confirmPassword
|
||||
);
|
||||
});
|
||||
|
||||
const passwordError = computed(() => {
|
||||
if (accountData.value.password && accountData.value.password.length < 8) {
|
||||
return t('setup.account.password_too_short');
|
||||
}
|
||||
if (
|
||||
accountData.value.confirmPassword &&
|
||||
accountData.value.password !== accountData.value.confirmPassword
|
||||
) {
|
||||
return t('setup.account.password_mismatch');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.account.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.account.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.account.username')">
|
||||
<input
|
||||
v-model="accountData.username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('setup.account.password')">
|
||||
<input
|
||||
v-model="accountData.password"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('setup.account.confirm_password')">
|
||||
<input
|
||||
v-model="accountData.confirmPassword"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<p v-if="passwordError" class="error-text">{{ passwordError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :disabled="!isValid" @click="setupStore.nextStep()">
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-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);
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
191
webui/src/components/setup/wizard-step-downloader.vue
Normal file
191
webui/src/components/setup/wizard-step-downloader.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { downloaderData, validation } = storeToRefs(setupStore);
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testMessage = ref('');
|
||||
const testSuccess = ref(false);
|
||||
|
||||
async function testConnection() {
|
||||
isTesting.value = true;
|
||||
testMessage.value = '';
|
||||
try {
|
||||
const result = await apiSetup.testDownloader({
|
||||
type: downloaderData.value.type,
|
||||
host: downloaderData.value.host,
|
||||
username: downloaderData.value.username,
|
||||
password: downloaderData.value.password,
|
||||
ssl: downloaderData.value.ssl,
|
||||
});
|
||||
testSuccess.value = result.success;
|
||||
const { returnUserLangText } = useMyI18n();
|
||||
testMessage.value = returnUserLangText({
|
||||
en: result.message_en,
|
||||
'zh-CN': result.message_zh,
|
||||
});
|
||||
validation.value.downloaderTested = result.success;
|
||||
} catch {
|
||||
testSuccess.value = false;
|
||||
testMessage.value = t('setup.downloader.test_failed');
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
setupStore.syncMediaPath();
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
const canTest = computed(() => {
|
||||
return downloaderData.value.host && downloaderData.value.username && downloaderData.value.password;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.downloader.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.downloader.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.downloader_set.host')">
|
||||
<input
|
||||
v-model="downloaderData.host"
|
||||
type="text"
|
||||
placeholder="172.17.0.1:8080"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.username')">
|
||||
<input
|
||||
v-model="downloaderData.username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.password')">
|
||||
<input
|
||||
v-model="downloaderData.password"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.path')">
|
||||
<input
|
||||
v-model="downloaderData.path"
|
||||
type="text"
|
||||
placeholder="/downloads/Bangumi"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.ssl')">
|
||||
<ab-switch v-model="downloaderData.ssl" />
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="!canTest || isTesting"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.downloader.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
:disabled="!validation.downloaderTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-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);
|
||||
}
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
100
webui/src/components/setup/wizard-step-media.vue
Normal file
100
webui/src/components/setup/wizard-step-media.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { mediaData } = storeToRefs(setupStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.media.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.media.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.media.path')">
|
||||
<input
|
||||
v-model="mediaData.path"
|
||||
type="text"
|
||||
placeholder="/downloads/Bangumi"
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<p class="path-hint">{{ t('setup.media.path_hint') }}</p>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" @click="setupStore.nextStep()">
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-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);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.path-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
200
webui/src/components/setup/wizard-step-notification.vue
Normal file
200
webui/src/components/setup/wizard-step-notification.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { notificationData, validation } = storeToRefs(setupStore);
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testMessage = ref('');
|
||||
const testSuccess = ref(false);
|
||||
|
||||
const notificationTypes = [
|
||||
{ id: 0, label: 'Telegram', value: 'telegram' },
|
||||
{ id: 1, label: 'Server Chan', value: 'server-chan' },
|
||||
{ id: 2, label: 'Bark', value: 'bark' },
|
||||
{ id: 3, label: 'WeChat Work', value: 'wecom' },
|
||||
];
|
||||
|
||||
async function testNotification() {
|
||||
isTesting.value = true;
|
||||
testMessage.value = '';
|
||||
try {
|
||||
const result = await apiSetup.testNotification({
|
||||
type: notificationData.value.type,
|
||||
token: notificationData.value.token,
|
||||
chat_id: notificationData.value.chat_id,
|
||||
});
|
||||
testSuccess.value = result.success;
|
||||
const { returnUserLangText } = useMyI18n();
|
||||
testMessage.value = returnUserLangText({
|
||||
en: result.message_en,
|
||||
'zh-CN': result.message_zh,
|
||||
});
|
||||
validation.value.notificationTested = result.success;
|
||||
} catch {
|
||||
testSuccess.value = false;
|
||||
testMessage.value = t('setup.notification.test_failed');
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function skipStep() {
|
||||
notificationData.value.skipped = true;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
notificationData.value.skipped = false;
|
||||
notificationData.value.enable = true;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
const canTest = computed(() => {
|
||||
return notificationData.value.token;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.notification.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.notification.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.notification_set.type')">
|
||||
<ab-select
|
||||
v-model="notificationData.type"
|
||||
:items="notificationTypes"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.notification_set.token')">
|
||||
<input
|
||||
v-model="notificationData.token"
|
||||
type="text"
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.notification_set.chat_id')">
|
||||
<input
|
||||
v-model="notificationData.chat_id"
|
||||
type="text"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="!canTest || isTesting"
|
||||
@click="testNotification"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.notification.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<div class="action-group">
|
||||
<ab-button size="small" type="secondary" @click="skipStep">
|
||||
{{ t('setup.nav.skip') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
:disabled="!validation.notificationTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-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);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
165
webui/src/components/setup/wizard-step-review.vue
Normal file
165
webui/src/components/setup/wizard-step-review.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { accountData, downloaderData, rssData, mediaData, notificationData, isLoading } =
|
||||
storeToRefs(setupStore);
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
async function completeSetup() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const request = setupStore.buildCompleteRequest();
|
||||
await apiSetup.complete(request);
|
||||
message.success(t('setup.review.success'));
|
||||
setupStore.$reset();
|
||||
router.push({ name: 'Login' });
|
||||
} catch (e) {
|
||||
message.error(t('setup.review.failed'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function maskPassword(pwd: string): string {
|
||||
if (pwd.length <= 2) return '**';
|
||||
return pwd[0] + '*'.repeat(pwd.length - 2) + pwd[pwd.length - 1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.review.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.review.subtitle') }}</p>
|
||||
|
||||
<div class="review-sections">
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.account.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.account.username') }}</span>
|
||||
<span class="review-value">{{ accountData.username }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.account.password') }}</span>
|
||||
<span class="review-value">{{ maskPassword(accountData.password) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.downloader.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.downloader_set.host') }}</span>
|
||||
<span class="review-value">{{ downloaderData.host }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.downloader_set.username') }}</span>
|
||||
<span class="review-value">{{ downloaderData.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.media.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.media.path') }}</span>
|
||||
<span class="review-value">{{ mediaData.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!rssData.skipped && rssData.url" class="review-section">
|
||||
<h4>{{ t('setup.rss.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.rss.feed_name') }}</span>
|
||||
<span class="review-value">{{ rssData.name || rssData.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!notificationData.skipped && notificationData.token" class="review-section">
|
||||
<h4>{{ t('setup.notification.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.notification_set.type') }}</span>
|
||||
<span class="review-value">{{ notificationData.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :disabled="isLoading" @click="completeSetup">
|
||||
{{ isLoading ? t('setup.review.completing') : t('setup.review.complete') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.review-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-section {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.review-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.review-label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.review-value {
|
||||
color: var(--color-text);
|
||||
font-family: monospace;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
207
webui/src/components/setup/wizard-step-rss.vue
Normal file
207
webui/src/components/setup/wizard-step-rss.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { rssData, validation } = storeToRefs(setupStore);
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testMessage = ref('');
|
||||
const testSuccess = ref(false);
|
||||
const feedTitle = ref('');
|
||||
const itemCount = ref(0);
|
||||
|
||||
async function testFeed() {
|
||||
if (!rssData.value.url) return;
|
||||
isTesting.value = true;
|
||||
testMessage.value = '';
|
||||
feedTitle.value = '';
|
||||
try {
|
||||
const result = await apiSetup.testRSS(rssData.value.url);
|
||||
testSuccess.value = result.success;
|
||||
const { returnUserLangText } = useMyI18n();
|
||||
testMessage.value = returnUserLangText({
|
||||
en: result.message_en,
|
||||
'zh-CN': result.message_zh,
|
||||
});
|
||||
if (result.success) {
|
||||
feedTitle.value = result.title || '';
|
||||
itemCount.value = result.item_count || 0;
|
||||
if (!rssData.value.name && result.title) {
|
||||
rssData.value.name = result.title;
|
||||
}
|
||||
validation.value.rssTested = true;
|
||||
}
|
||||
} catch {
|
||||
testSuccess.value = false;
|
||||
testMessage.value = t('setup.rss.test_failed');
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function skipStep() {
|
||||
rssData.value.skipped = true;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
rssData.value.skipped = false;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.rss.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.rss.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.rss.url')">
|
||||
<input
|
||||
v-model="rssData.url"
|
||||
type="text"
|
||||
placeholder="https://mikanani.me/RSS/..."
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label v-if="rssData.name" :label="t('setup.rss.feed_name')">
|
||||
<input
|
||||
v-model="rssData.name"
|
||||
type="text"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="!rssData.url || isTesting"
|
||||
@click="testFeed"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.rss.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedTitle" class="feed-info">
|
||||
<p><strong>{{ t('setup.rss.feed_title') }}:</strong> {{ feedTitle }}</p>
|
||||
<p><strong>{{ t('setup.rss.item_count') }}:</strong> {{ itemCount }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<div class="action-group">
|
||||
<ab-button size="small" type="secondary" @click="skipStep">
|
||||
{{ t('setup.nav.skip') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
:disabled="!validation.rssTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-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);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
}
|
||||
}
|
||||
|
||||
.feed-info {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
&:last-child { margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
51
webui/src/components/setup/wizard-step-welcome.vue
Normal file
51
webui/src/components/setup/wizard-step-welcome.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.welcome.title')" class="wizard-step">
|
||||
<div class="welcome-content">
|
||||
<p class="welcome-subtitle">{{ t('setup.welcome.subtitle') }}</p>
|
||||
<p class="welcome-description">{{ t('setup.welcome.description') }}</p>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<div></div>
|
||||
<ab-button size="small" @click="setupStore.nextStep()">
|
||||
{{ t('setup.welcome.start') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -240,6 +240,67 @@
|
||||
"subtitle": "Add anime from RSS to see your weekly schedule"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"welcome": {
|
||||
"title": "Welcome to AutoBangumi",
|
||||
"subtitle": "Let's set up your anime downloading system.",
|
||||
"description": "This wizard will guide you through the initial configuration. You can skip optional steps and configure them later.",
|
||||
"start": "Get Started"
|
||||
},
|
||||
"account": {
|
||||
"title": "Create Your Account",
|
||||
"subtitle": "Change the default credentials for security.",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirm_password": "Confirm Password",
|
||||
"password_mismatch": "Passwords do not match",
|
||||
"password_too_short": "Password must be at least 8 characters"
|
||||
},
|
||||
"downloader": {
|
||||
"title": "Download Client",
|
||||
"subtitle": "Connect to your qBittorrent instance.",
|
||||
"test": "Test Connection",
|
||||
"testing": "Testing...",
|
||||
"test_success": "Connection successful",
|
||||
"test_failed": "Connection failed"
|
||||
},
|
||||
"rss": {
|
||||
"title": "RSS Source",
|
||||
"subtitle": "Add your first anime RSS feed.",
|
||||
"url": "RSS Feed URL",
|
||||
"feed_name": "Feed Name",
|
||||
"test": "Test Feed",
|
||||
"test_failed": "Failed to fetch feed",
|
||||
"feed_title": "Feed Title",
|
||||
"item_count": "Items Found"
|
||||
},
|
||||
"media": {
|
||||
"title": "Media Library",
|
||||
"subtitle": "Configure the download and organization path.",
|
||||
"path": "Download Path",
|
||||
"path_hint": "Episodes will be organized into subdirectories automatically."
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Get notified about new episodes (optional).",
|
||||
"test": "Send Test",
|
||||
"test_failed": "Notification test failed"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Settings",
|
||||
"subtitle": "Confirm your configuration before completing setup.",
|
||||
"complete": "Complete Setup",
|
||||
"completing": "Setting up...",
|
||||
"success": "Setup complete! Please log in with your new credentials.",
|
||||
"failed": "Setup failed. Please try again."
|
||||
},
|
||||
"nav": {
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"skip": "Skip",
|
||||
"step": "Step {current} of {total}"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"calendar": "Calendar",
|
||||
"config": "Config",
|
||||
|
||||
@@ -240,6 +240,67 @@
|
||||
"subtitle": "从 RSS 添加番剧后即可查看每周放送时间"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"welcome": {
|
||||
"title": "欢迎使用 AutoBangumi",
|
||||
"subtitle": "让我们来设置你的自动追番系统。",
|
||||
"description": "本向导将引导你完成初始配置。你可以跳过可选步骤,稍后再进行配置。",
|
||||
"start": "开始设置"
|
||||
},
|
||||
"account": {
|
||||
"title": "创建账户",
|
||||
"subtitle": "为了安全,请修改默认登录凭证。",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirm_password": "确认密码",
|
||||
"password_mismatch": "两次输入的密码不一致",
|
||||
"password_too_short": "密码长度不能少于 8 个字符"
|
||||
},
|
||||
"downloader": {
|
||||
"title": "下载客户端",
|
||||
"subtitle": "连接到你的 qBittorrent 实例。",
|
||||
"test": "测试连接",
|
||||
"testing": "测试中...",
|
||||
"test_success": "连接成功",
|
||||
"test_failed": "连接失败"
|
||||
},
|
||||
"rss": {
|
||||
"title": "RSS 订阅源",
|
||||
"subtitle": "添加你的第一个番剧 RSS 源。",
|
||||
"url": "RSS 源地址",
|
||||
"feed_name": "源名称",
|
||||
"test": "测试订阅源",
|
||||
"test_failed": "获取订阅源失败",
|
||||
"feed_title": "源标题",
|
||||
"item_count": "条目数量"
|
||||
},
|
||||
"media": {
|
||||
"title": "媒体库",
|
||||
"subtitle": "配置下载和整理路径。",
|
||||
"path": "下载路径",
|
||||
"path_hint": "剧集将自动整理到子目录中。"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知设置",
|
||||
"subtitle": "获取新集数的通知推送(可选)。",
|
||||
"test": "发送测试",
|
||||
"test_failed": "通知测试失败"
|
||||
},
|
||||
"review": {
|
||||
"title": "确认设置",
|
||||
"subtitle": "在完成设置前确认你的配置。",
|
||||
"complete": "完成设置",
|
||||
"completing": "设置中...",
|
||||
"success": "设置完成!请使用新凭证登录。",
|
||||
"failed": "设置失败,请重试。"
|
||||
},
|
||||
"nav": {
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"skip": "跳过",
|
||||
"step": "第 {current} 步,共 {total} 步"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"calendar": "番剧日历",
|
||||
"config": "设置",
|
||||
|
||||
36
webui/src/pages/setup.vue
Normal file
36
webui/src/pages/setup.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
definePage({
|
||||
name: 'Setup',
|
||||
});
|
||||
|
||||
const setupStore = useSetupStore();
|
||||
const { currentStep, currentStepIndex, steps } = storeToRefs(setupStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-setup">
|
||||
<wizard-container
|
||||
:current-step="currentStepIndex"
|
||||
:total-steps="steps.length"
|
||||
>
|
||||
<wizard-step-welcome v-if="currentStep === 'welcome'" />
|
||||
<wizard-step-account v-else-if="currentStep === 'account'" />
|
||||
<wizard-step-downloader v-else-if="currentStep === 'downloader'" />
|
||||
<wizard-step-rss v-else-if="currentStep === 'rss'" />
|
||||
<wizard-step-media v-else-if="currentStep === 'media'" />
|
||||
<wizard-step-notification v-else-if="currentStep === 'notification'" />
|
||||
<wizard-step-review v-else-if="currentStep === 'review'" />
|
||||
</wizard-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-setup {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,35 @@ const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
let setupChecked = false;
|
||||
let needSetup = false;
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const { type, url } = storeToRefs(usePlayerStore());
|
||||
|
||||
if (!isLoggedIn.value && to.path !== '/login') {
|
||||
// Check setup status once per session
|
||||
if (!setupChecked && to.path !== '/setup') {
|
||||
try {
|
||||
const status = await apiSetup.getStatus();
|
||||
needSetup = status.need_setup;
|
||||
} catch {
|
||||
// If check fails, proceed normally
|
||||
}
|
||||
setupChecked = true;
|
||||
}
|
||||
|
||||
// Redirect to setup if needed
|
||||
if (needSetup && to.path !== '/setup') {
|
||||
return { name: 'Setup' };
|
||||
}
|
||||
|
||||
// Prevent going to setup after it's completed
|
||||
if (to.path === '/setup' && setupChecked && !needSetup) {
|
||||
return { name: 'Login' };
|
||||
}
|
||||
|
||||
if (!isLoggedIn.value && to.path !== '/login' && to.path !== '/setup') {
|
||||
return { name: 'Login' };
|
||||
} else if (isLoggedIn.value && to.path === '/login') {
|
||||
return { name: 'Index' };
|
||||
|
||||
149
webui/src/store/setup.ts
Normal file
149
webui/src/store/setup.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { SetupCompleteRequest, WizardStep } from '#/setup';
|
||||
|
||||
export const useSetupStore = defineStore('setup', () => {
|
||||
const steps: WizardStep[] = [
|
||||
'welcome',
|
||||
'account',
|
||||
'downloader',
|
||||
'rss',
|
||||
'media',
|
||||
'notification',
|
||||
'review',
|
||||
];
|
||||
|
||||
const currentStepIndex = ref(0);
|
||||
const currentStep = computed(() => steps[currentStepIndex.value]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Form data
|
||||
const accountData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const downloaderData = reactive({
|
||||
type: 'qbittorrent',
|
||||
host: '',
|
||||
username: '',
|
||||
password: '',
|
||||
path: '/downloads/Bangumi',
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
const rssData = reactive({
|
||||
url: '',
|
||||
name: '',
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
const mediaData = reactive({
|
||||
path: '/downloads/Bangumi',
|
||||
});
|
||||
|
||||
const notificationData = reactive({
|
||||
enable: false,
|
||||
type: 'telegram',
|
||||
token: '',
|
||||
chat_id: '',
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
// Validation states
|
||||
const validation = reactive({
|
||||
downloaderTested: false,
|
||||
rssTested: false,
|
||||
notificationTested: false,
|
||||
});
|
||||
|
||||
// Navigation
|
||||
function nextStep() {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStepIndex.value > 0) {
|
||||
currentStepIndex.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(index: number) {
|
||||
if (index >= 0 && index < steps.length) {
|
||||
currentStepIndex.value = index;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync media path with downloader path
|
||||
function syncMediaPath() {
|
||||
mediaData.path = downloaderData.path;
|
||||
}
|
||||
|
||||
// Build final request
|
||||
function buildCompleteRequest(): SetupCompleteRequest {
|
||||
return {
|
||||
username: accountData.username,
|
||||
password: accountData.password,
|
||||
downloader_type: downloaderData.type,
|
||||
downloader_host: downloaderData.host,
|
||||
downloader_username: downloaderData.username,
|
||||
downloader_password: downloaderData.password,
|
||||
downloader_path: mediaData.path,
|
||||
downloader_ssl: downloaderData.ssl,
|
||||
rss_url: rssData.skipped ? '' : rssData.url,
|
||||
rss_name: rssData.skipped ? '' : rssData.name,
|
||||
notification_enable: !notificationData.skipped && notificationData.enable,
|
||||
notification_type: notificationData.type,
|
||||
notification_token: notificationData.token,
|
||||
notification_chat_id: notificationData.chat_id,
|
||||
};
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
currentStepIndex.value = 0;
|
||||
isLoading.value = false;
|
||||
Object.assign(accountData, { username: '', password: '', confirmPassword: '' });
|
||||
Object.assign(downloaderData, {
|
||||
type: 'qbittorrent',
|
||||
host: '',
|
||||
username: '',
|
||||
password: '',
|
||||
path: '/downloads/Bangumi',
|
||||
ssl: false,
|
||||
});
|
||||
Object.assign(rssData, { url: '', name: '', skipped: false });
|
||||
Object.assign(mediaData, { path: '/downloads/Bangumi' });
|
||||
Object.assign(notificationData, {
|
||||
enable: false,
|
||||
type: 'telegram',
|
||||
token: '',
|
||||
chat_id: '',
|
||||
skipped: false,
|
||||
});
|
||||
Object.assign(validation, {
|
||||
downloaderTested: false,
|
||||
rssTested: false,
|
||||
notificationTested: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
isLoading,
|
||||
accountData,
|
||||
downloaderData,
|
||||
rssData,
|
||||
mediaData,
|
||||
notificationData,
|
||||
validation,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
syncMediaPath,
|
||||
buildCompleteRequest,
|
||||
$reset,
|
||||
};
|
||||
});
|
||||
2
webui/types/dts/auto-imports.d.ts
vendored
2
webui/types/dts/auto-imports.d.ts
vendored
@@ -16,6 +16,7 @@ declare global {
|
||||
const apiProgram: typeof import('../../src/api/program')['apiProgram']
|
||||
const apiRSS: typeof import('../../src/api/rss')['apiRSS']
|
||||
const apiSearch: typeof import('../../src/api/search')['apiSearch']
|
||||
const apiSetup: typeof import('../../src/api/setup')['apiSetup']
|
||||
const assert: typeof import('vitest')['assert']
|
||||
const axios: typeof import('../../src/utils/axios')['axios']
|
||||
const beforeAll: typeof import('vitest')['beforeAll']
|
||||
@@ -113,6 +114,7 @@ declare global {
|
||||
const useRouter: typeof import('vue-router/auto')['useRouter']
|
||||
const useSafeArea: typeof import('../../src/hooks/useSafeArea')['useSafeArea']
|
||||
const useSearchStore: typeof import('../../src/store/search')['useSearchStore']
|
||||
const useSetupStore: typeof import('../../src/store/setup')['useSetupStore']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const vi: typeof import('vitest')['vi']
|
||||
const vitest: typeof import('vitest')['vitest']
|
||||
|
||||
8
webui/types/dts/components.d.ts
vendored
8
webui/types/dts/components.d.ts
vendored
@@ -52,5 +52,13 @@ declare module '@vue/runtime-core' {
|
||||
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
WizardContainer: typeof import('./../../src/components/setup/wizard-container.vue')['default']
|
||||
WizardStepAccount: typeof import('./../../src/components/setup/wizard-step-account.vue')['default']
|
||||
WizardStepDownloader: typeof import('./../../src/components/setup/wizard-step-downloader.vue')['default']
|
||||
WizardStepMedia: typeof import('./../../src/components/setup/wizard-step-media.vue')['default']
|
||||
WizardStepNotification: typeof import('./../../src/components/setup/wizard-step-notification.vue')['default']
|
||||
WizardStepReview: typeof import('./../../src/components/setup/wizard-step-review.vue')['default']
|
||||
WizardStepRss: typeof import('./../../src/components/setup/wizard-step-rss.vue')['default']
|
||||
WizardStepWelcome: typeof import('./../../src/components/setup/wizard-step-welcome.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
1
webui/types/dts/router-type.d.ts
vendored
1
webui/types/dts/router-type.d.ts
vendored
@@ -48,6 +48,7 @@ declare module 'vue-router/auto/routes' {
|
||||
'Player': RouteRecordInfo<'Player', '/player', Record<never, never>, Record<never, never>>,
|
||||
'RSS': RouteRecordInfo<'RSS', '/rss', Record<never, never>, Record<never, never>>,
|
||||
'Login': RouteRecordInfo<'Login', '/login', Record<never, never>, Record<never, never>>,
|
||||
'Setup': RouteRecordInfo<'Setup', '/setup', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
webui/types/setup.ts
Normal file
56
webui/types/setup.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface SetupStatus {
|
||||
need_setup: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface TestDownloaderRequest {
|
||||
type: string;
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
export interface TestRSSRequest {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TestNotificationRequest {
|
||||
type: string;
|
||||
token: string;
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
message_en: string;
|
||||
message_zh: string;
|
||||
title?: string;
|
||||
item_count?: number;
|
||||
}
|
||||
|
||||
export interface SetupCompleteRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
downloader_type: string;
|
||||
downloader_host: string;
|
||||
downloader_username: string;
|
||||
downloader_password: string;
|
||||
downloader_path: string;
|
||||
downloader_ssl: boolean;
|
||||
rss_url: string;
|
||||
rss_name: string;
|
||||
notification_enable: boolean;
|
||||
notification_type: string;
|
||||
notification_token: string;
|
||||
notification_chat_id: string;
|
||||
}
|
||||
|
||||
export type WizardStep =
|
||||
| 'welcome'
|
||||
| 'account'
|
||||
| 'downloader'
|
||||
| 'rss'
|
||||
| 'media'
|
||||
| 'notification'
|
||||
| 'review';
|
||||
@@ -52,6 +52,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'src/components/basic',
|
||||
'src/components/layout',
|
||||
'src/components/setting',
|
||||
'src/components/setup',
|
||||
],
|
||||
}),
|
||||
VueI18nPlugin({
|
||||
|
||||
Reference in New Issue
Block a user