mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-14 18:41:04 +08:00
feat: add first-run setup wizard with guided configuration
Add a multi-step setup wizard that guides new users through initial configuration on first run. The wizard covers account credentials, download client connection (with test), RSS source, media paths, and optional notification setup. Backend: new /api/v1/setup/ endpoints with sentinel file mechanism. Frontend: 7-step wizard with validation and i18n (en/zh-CN). 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