diff --git a/backend/src/module/api/__init__.py b/backend/src/module/api/__init__.py index d18cb0ed..23b90263 100644 --- a/backend/src/module/api/__init__.py +++ b/backend/src/module/api/__init__.py @@ -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) diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py new file mode 100644 index 00000000..d879a0ec --- /dev/null +++ b/backend/src/module/api/setup.py @@ -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}", + ) diff --git a/backend/src/module/checker/checker.py b/backend/src/module/checker/checker.py index 97ccddb2..27fdb11d 100644 --- a/backend/src/module/checker/checker.py +++ b/backend/src/module/checker/checker.py @@ -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]: diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py new file mode 100644 index 00000000..371c8a4c --- /dev/null +++ b/backend/src/test/test_setup.py @@ -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") diff --git a/webui/src/api/setup.ts b/webui/src/api/setup.ts new file mode 100644 index 00000000..e17ccbca --- /dev/null +++ b/webui/src/api/setup.ts @@ -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('api/v1/setup/status'); + return data; + }, + + async testDownloader(config: TestDownloaderRequest) { + const { data } = await axios.post( + 'api/v1/setup/test-downloader', + config + ); + return data; + }, + + async testRSS(url: string) { + const { data } = await axios.post('api/v1/setup/test-rss', { + url, + }); + return data; + }, + + async testNotification(config: TestNotificationRequest) { + const { data } = await axios.post( + 'api/v1/setup/test-notification', + config + ); + return data; + }, + + async complete(config: SetupCompleteRequest) { + const { data } = await axios.post( + 'api/v1/setup/complete', + config + ); + return data; + }, +}; diff --git a/webui/src/components/setup/wizard-container.vue b/webui/src/components/setup/wizard-container.vue new file mode 100644 index 00000000..995ea7cb --- /dev/null +++ b/webui/src/components/setup/wizard-container.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-account.vue b/webui/src/components/setup/wizard-step-account.vue new file mode 100644 index 00000000..e9b029a8 --- /dev/null +++ b/webui/src/components/setup/wizard-step-account.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-downloader.vue b/webui/src/components/setup/wizard-step-downloader.vue new file mode 100644 index 00000000..30429d35 --- /dev/null +++ b/webui/src/components/setup/wizard-step-downloader.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-media.vue b/webui/src/components/setup/wizard-step-media.vue new file mode 100644 index 00000000..10180a74 --- /dev/null +++ b/webui/src/components/setup/wizard-step-media.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-notification.vue b/webui/src/components/setup/wizard-step-notification.vue new file mode 100644 index 00000000..8d30208d --- /dev/null +++ b/webui/src/components/setup/wizard-step-notification.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-review.vue b/webui/src/components/setup/wizard-step-review.vue new file mode 100644 index 00000000..5df99599 --- /dev/null +++ b/webui/src/components/setup/wizard-step-review.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-rss.vue b/webui/src/components/setup/wizard-step-rss.vue new file mode 100644 index 00000000..bb8bf0a1 --- /dev/null +++ b/webui/src/components/setup/wizard-step-rss.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/webui/src/components/setup/wizard-step-welcome.vue b/webui/src/components/setup/wizard-step-welcome.vue new file mode 100644 index 00000000..af42ec16 --- /dev/null +++ b/webui/src/components/setup/wizard-step-welcome.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index b6083516..76f5a381 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -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", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index c9dbb8f5..0bc4329a 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -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": "设置", diff --git a/webui/src/pages/setup.vue b/webui/src/pages/setup.vue new file mode 100644 index 00000000..9da2b154 --- /dev/null +++ b/webui/src/pages/setup.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/webui/src/router/index.ts b/webui/src/router/index.ts index 6e21876a..4e20def5 100644 --- a/webui/src/router/index.ts +++ b/webui/src/router/index.ts @@ -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' }; diff --git a/webui/src/store/setup.ts b/webui/src/store/setup.ts new file mode 100644 index 00000000..691641ba --- /dev/null +++ b/webui/src/store/setup.ts @@ -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, + }; +}); diff --git a/webui/types/dts/auto-imports.d.ts b/webui/types/dts/auto-imports.d.ts index b55d2834..3302430e 100644 --- a/webui/types/dts/auto-imports.d.ts +++ b/webui/types/dts/auto-imports.d.ts @@ -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'] diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts index e2b386ab..6aaf5be5 100644 --- a/webui/types/dts/components.d.ts +++ b/webui/types/dts/components.d.ts @@ -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'] } } diff --git a/webui/types/dts/router-type.d.ts b/webui/types/dts/router-type.d.ts index 66ba5cf1..b3023e7b 100644 --- a/webui/types/dts/router-type.d.ts +++ b/webui/types/dts/router-type.d.ts @@ -48,6 +48,7 @@ declare module 'vue-router/auto/routes' { 'Player': RouteRecordInfo<'Player', '/player', Record, Record>, 'RSS': RouteRecordInfo<'RSS', '/rss', Record, Record>, 'Login': RouteRecordInfo<'Login', '/login', Record, Record>, + 'Setup': RouteRecordInfo<'Setup', '/setup', Record, Record>, } } diff --git a/webui/types/setup.ts b/webui/types/setup.ts new file mode 100644 index 00000000..b5313328 --- /dev/null +++ b/webui/types/setup.ts @@ -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'; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index a4b88a49..876cf4a1 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -52,6 +52,7 @@ export default defineConfig(({ mode }) => ({ 'src/components/basic', 'src/components/layout', 'src/components/setting', + 'src/components/setup', ], }), VueI18nPlugin({