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:
Estrella Pan
2026-01-24 08:16:41 +01:00
parent 9235b07b41
commit 5382aec8dc
23 changed files with 2108 additions and 5 deletions

View File

@@ -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)

View 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}",
)

View File

@@ -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]:

View 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
View 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;
},
};

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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",

View File

@@ -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
View 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>

View File

@@ -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
View 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,
};
});

View File

@@ -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']

View File

@@ -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']
}
}

View File

@@ -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
View 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';

View File

@@ -52,6 +52,7 @@ export default defineConfig(({ mode }) => ({
'src/components/basic',
'src/components/layout',
'src/components/setting',
'src/components/setup',
],
}),
VueI18nPlugin({