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

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