mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-17 12:11:14 +08:00
feat: add first-run setup wizard with guided configuration
Add a multi-step setup wizard that guides new users through initial configuration on first run. The wizard covers account credentials, download client connection (with test), RSS source, media paths, and optional notification setup. Backend: new /api/v1/setup/ endpoints with sentinel file mechanism. Frontend: 7-step wizard with validation and i18n (en/zh-CN). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
46
webui/src/api/setup.ts
Normal file
46
webui/src/api/setup.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
SetupCompleteRequest,
|
||||
SetupStatus,
|
||||
TestDownloaderRequest,
|
||||
TestNotificationRequest,
|
||||
TestResult,
|
||||
} from '#/setup';
|
||||
import type { ApiSuccess } from '#/api';
|
||||
|
||||
export const apiSetup = {
|
||||
async getStatus() {
|
||||
const { data } = await axios.get<SetupStatus>('api/v1/setup/status');
|
||||
return data;
|
||||
},
|
||||
|
||||
async testDownloader(config: TestDownloaderRequest) {
|
||||
const { data } = await axios.post<TestResult>(
|
||||
'api/v1/setup/test-downloader',
|
||||
config
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async testRSS(url: string) {
|
||||
const { data } = await axios.post<TestResult>('api/v1/setup/test-rss', {
|
||||
url,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async testNotification(config: TestNotificationRequest) {
|
||||
const { data } = await axios.post<TestResult>(
|
||||
'api/v1/setup/test-notification',
|
||||
config
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async complete(config: SetupCompleteRequest) {
|
||||
const { data } = await axios.post<ApiSuccess>(
|
||||
'api/v1/setup/complete',
|
||||
config
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
70
webui/src/components/setup/wizard-container.vue
Normal file
70
webui/src/components/setup/wizard-container.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}>();
|
||||
|
||||
const { t } = useMyI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wizard-container">
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-bar">
|
||||
<div
|
||||
class="wizard-progress-fill"
|
||||
:style="{ width: `${(currentStep / (totalSteps - 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="wizard-step-indicator">
|
||||
{{ t('setup.nav.step', { current: currentStep + 1, total: totalSteps }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-container {
|
||||
width: 480px;
|
||||
max-width: 92%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.wizard-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wizard-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.wizard-step-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wizard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
133
webui/src/components/setup/wizard-step-account.vue
Normal file
133
webui/src/components/setup/wizard-step-account.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { accountData } = storeToRefs(setupStore);
|
||||
|
||||
const isValid = computed(() => {
|
||||
return (
|
||||
accountData.value.username.length >= 4 &&
|
||||
accountData.value.password.length >= 8 &&
|
||||
accountData.value.password === accountData.value.confirmPassword
|
||||
);
|
||||
});
|
||||
|
||||
const passwordError = computed(() => {
|
||||
if (accountData.value.password && accountData.value.password.length < 8) {
|
||||
return t('setup.account.password_too_short');
|
||||
}
|
||||
if (
|
||||
accountData.value.confirmPassword &&
|
||||
accountData.value.password !== accountData.value.confirmPassword
|
||||
) {
|
||||
return t('setup.account.password_mismatch');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.account.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.account.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.account.username')">
|
||||
<input
|
||||
v-model="accountData.username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('setup.account.password')">
|
||||
<input
|
||||
v-model="accountData.password"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('setup.account.confirm_password')">
|
||||
<input
|
||||
v-model="accountData.confirmPassword"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<p v-if="passwordError" class="error-text">{{ passwordError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :disabled="!isValid" @click="setupStore.nextStep()">
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
191
webui/src/components/setup/wizard-step-downloader.vue
Normal file
191
webui/src/components/setup/wizard-step-downloader.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { downloaderData, validation } = storeToRefs(setupStore);
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testMessage = ref('');
|
||||
const testSuccess = ref(false);
|
||||
|
||||
async function testConnection() {
|
||||
isTesting.value = true;
|
||||
testMessage.value = '';
|
||||
try {
|
||||
const result = await apiSetup.testDownloader({
|
||||
type: downloaderData.value.type,
|
||||
host: downloaderData.value.host,
|
||||
username: downloaderData.value.username,
|
||||
password: downloaderData.value.password,
|
||||
ssl: downloaderData.value.ssl,
|
||||
});
|
||||
testSuccess.value = result.success;
|
||||
const { returnUserLangText } = useMyI18n();
|
||||
testMessage.value = returnUserLangText({
|
||||
en: result.message_en,
|
||||
'zh-CN': result.message_zh,
|
||||
});
|
||||
validation.value.downloaderTested = result.success;
|
||||
} catch {
|
||||
testSuccess.value = false;
|
||||
testMessage.value = t('setup.downloader.test_failed');
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
setupStore.syncMediaPath();
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
const canTest = computed(() => {
|
||||
return downloaderData.value.host && downloaderData.value.username && downloaderData.value.password;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.downloader.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.downloader.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.downloader_set.host')">
|
||||
<input
|
||||
v-model="downloaderData.host"
|
||||
type="text"
|
||||
placeholder="172.17.0.1:8080"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.username')">
|
||||
<input
|
||||
v-model="downloaderData.username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.password')">
|
||||
<input
|
||||
v-model="downloaderData.password"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.path')">
|
||||
<input
|
||||
v-model="downloaderData.path"
|
||||
type="text"
|
||||
placeholder="/downloads/Bangumi"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.ssl')">
|
||||
<ab-switch v-model="downloaderData.ssl" />
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="!canTest || isTesting"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.downloader.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
:disabled="!validation.downloaderTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
100
webui/src/components/setup/wizard-step-media.vue
Normal file
100
webui/src/components/setup/wizard-step-media.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { mediaData } = storeToRefs(setupStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.media.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.media.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.media.path')">
|
||||
<input
|
||||
v-model="mediaData.path"
|
||||
type="text"
|
||||
placeholder="/downloads/Bangumi"
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<p class="path-hint">{{ t('setup.media.path_hint') }}</p>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" @click="setupStore.nextStep()">
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.path-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
200
webui/src/components/setup/wizard-step-notification.vue
Normal file
200
webui/src/components/setup/wizard-step-notification.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { notificationData, validation } = storeToRefs(setupStore);
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testMessage = ref('');
|
||||
const testSuccess = ref(false);
|
||||
|
||||
const notificationTypes = [
|
||||
{ id: 0, label: 'Telegram', value: 'telegram' },
|
||||
{ id: 1, label: 'Server Chan', value: 'server-chan' },
|
||||
{ id: 2, label: 'Bark', value: 'bark' },
|
||||
{ id: 3, label: 'WeChat Work', value: 'wecom' },
|
||||
];
|
||||
|
||||
async function testNotification() {
|
||||
isTesting.value = true;
|
||||
testMessage.value = '';
|
||||
try {
|
||||
const result = await apiSetup.testNotification({
|
||||
type: notificationData.value.type,
|
||||
token: notificationData.value.token,
|
||||
chat_id: notificationData.value.chat_id,
|
||||
});
|
||||
testSuccess.value = result.success;
|
||||
const { returnUserLangText } = useMyI18n();
|
||||
testMessage.value = returnUserLangText({
|
||||
en: result.message_en,
|
||||
'zh-CN': result.message_zh,
|
||||
});
|
||||
validation.value.notificationTested = result.success;
|
||||
} catch {
|
||||
testSuccess.value = false;
|
||||
testMessage.value = t('setup.notification.test_failed');
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function skipStep() {
|
||||
notificationData.value.skipped = true;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
notificationData.value.skipped = false;
|
||||
notificationData.value.enable = true;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
const canTest = computed(() => {
|
||||
return notificationData.value.token;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.notification.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.notification.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.notification_set.type')">
|
||||
<ab-select
|
||||
v-model="notificationData.type"
|
||||
:items="notificationTypes"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.notification_set.token')">
|
||||
<input
|
||||
v-model="notificationData.token"
|
||||
type="text"
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.notification_set.chat_id')">
|
||||
<input
|
||||
v-model="notificationData.chat_id"
|
||||
type="text"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="!canTest || isTesting"
|
||||
@click="testNotification"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.notification.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<div class="action-group">
|
||||
<ab-button size="small" type="secondary" @click="skipStep">
|
||||
{{ t('setup.nav.skip') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
:disabled="!validation.notificationTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
165
webui/src/components/setup/wizard-step-review.vue
Normal file
165
webui/src/components/setup/wizard-step-review.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { accountData, downloaderData, rssData, mediaData, notificationData, isLoading } =
|
||||
storeToRefs(setupStore);
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
async function completeSetup() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const request = setupStore.buildCompleteRequest();
|
||||
await apiSetup.complete(request);
|
||||
message.success(t('setup.review.success'));
|
||||
setupStore.$reset();
|
||||
router.push({ name: 'Login' });
|
||||
} catch (e) {
|
||||
message.error(t('setup.review.failed'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function maskPassword(pwd: string): string {
|
||||
if (pwd.length <= 2) return '**';
|
||||
return pwd[0] + '*'.repeat(pwd.length - 2) + pwd[pwd.length - 1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.review.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.review.subtitle') }}</p>
|
||||
|
||||
<div class="review-sections">
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.account.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.account.username') }}</span>
|
||||
<span class="review-value">{{ accountData.username }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.account.password') }}</span>
|
||||
<span class="review-value">{{ maskPassword(accountData.password) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.downloader.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.downloader_set.host') }}</span>
|
||||
<span class="review-value">{{ downloaderData.host }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.downloader_set.username') }}</span>
|
||||
<span class="review-value">{{ downloaderData.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.media.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.media.path') }}</span>
|
||||
<span class="review-value">{{ mediaData.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!rssData.skipped && rssData.url" class="review-section">
|
||||
<h4>{{ t('setup.rss.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.rss.feed_name') }}</span>
|
||||
<span class="review-value">{{ rssData.name || rssData.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!notificationData.skipped && notificationData.token" class="review-section">
|
||||
<h4>{{ t('setup.notification.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.notification_set.type') }}</span>
|
||||
<span class="review-value">{{ notificationData.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :disabled="isLoading" @click="completeSetup">
|
||||
{{ isLoading ? t('setup.review.completing') : t('setup.review.complete') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.review-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-section {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.review-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.review-label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.review-value {
|
||||
color: var(--color-text);
|
||||
font-family: monospace;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
207
webui/src/components/setup/wizard-step-rss.vue
Normal file
207
webui/src/components/setup/wizard-step-rss.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { rssData, validation } = storeToRefs(setupStore);
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testMessage = ref('');
|
||||
const testSuccess = ref(false);
|
||||
const feedTitle = ref('');
|
||||
const itemCount = ref(0);
|
||||
|
||||
async function testFeed() {
|
||||
if (!rssData.value.url) return;
|
||||
isTesting.value = true;
|
||||
testMessage.value = '';
|
||||
feedTitle.value = '';
|
||||
try {
|
||||
const result = await apiSetup.testRSS(rssData.value.url);
|
||||
testSuccess.value = result.success;
|
||||
const { returnUserLangText } = useMyI18n();
|
||||
testMessage.value = returnUserLangText({
|
||||
en: result.message_en,
|
||||
'zh-CN': result.message_zh,
|
||||
});
|
||||
if (result.success) {
|
||||
feedTitle.value = result.title || '';
|
||||
itemCount.value = result.item_count || 0;
|
||||
if (!rssData.value.name && result.title) {
|
||||
rssData.value.name = result.title;
|
||||
}
|
||||
validation.value.rssTested = true;
|
||||
}
|
||||
} catch {
|
||||
testSuccess.value = false;
|
||||
testMessage.value = t('setup.rss.test_failed');
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function skipStep() {
|
||||
rssData.value.skipped = true;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
rssData.value.skipped = false;
|
||||
setupStore.nextStep();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.rss.title')" class="wizard-step">
|
||||
<div class="step-content">
|
||||
<p class="step-subtitle">{{ t('setup.rss.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.rss.url')">
|
||||
<input
|
||||
v-model="rssData.url"
|
||||
type="text"
|
||||
placeholder="https://mikanani.me/RSS/..."
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-label v-if="rssData.name" :label="t('setup.rss.feed_name')">
|
||||
<input
|
||||
v-model="rssData.name"
|
||||
type="text"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="secondary"
|
||||
:disabled="!rssData.url || isTesting"
|
||||
@click="testFeed"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.rss.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedTitle" class="feed-info">
|
||||
<p><strong>{{ t('setup.rss.feed_title') }}:</strong> {{ feedTitle }}</p>
|
||||
<p><strong>{{ t('setup.rss.item_count') }}:</strong> {{ itemCount }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<div class="action-group">
|
||||
<ab-button size="small" type="secondary" @click="skipStep">
|
||||
{{ t('setup.nav.skip') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
:disabled="!validation.rssTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.step-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
}
|
||||
}
|
||||
|
||||
.feed-info {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
&:last-child { margin: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
51
webui/src/components/setup/wizard-step-welcome.vue
Normal file
51
webui/src/components/setup/wizard-step-welcome.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="t('setup.welcome.title')" class="wizard-step">
|
||||
<div class="welcome-content">
|
||||
<p class="welcome-subtitle">{{ t('setup.welcome.subtitle') }}</p>
|
||||
<p class="welcome-description">{{ t('setup.welcome.description') }}</p>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<div></div>
|
||||
<ab-button size="small" @click="setupStore.nextStep()">
|
||||
{{ t('setup.welcome.start') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -240,6 +240,67 @@
|
||||
"subtitle": "Add anime from RSS to see your weekly schedule"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"welcome": {
|
||||
"title": "Welcome to AutoBangumi",
|
||||
"subtitle": "Let's set up your anime downloading system.",
|
||||
"description": "This wizard will guide you through the initial configuration. You can skip optional steps and configure them later.",
|
||||
"start": "Get Started"
|
||||
},
|
||||
"account": {
|
||||
"title": "Create Your Account",
|
||||
"subtitle": "Change the default credentials for security.",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirm_password": "Confirm Password",
|
||||
"password_mismatch": "Passwords do not match",
|
||||
"password_too_short": "Password must be at least 8 characters"
|
||||
},
|
||||
"downloader": {
|
||||
"title": "Download Client",
|
||||
"subtitle": "Connect to your qBittorrent instance.",
|
||||
"test": "Test Connection",
|
||||
"testing": "Testing...",
|
||||
"test_success": "Connection successful",
|
||||
"test_failed": "Connection failed"
|
||||
},
|
||||
"rss": {
|
||||
"title": "RSS Source",
|
||||
"subtitle": "Add your first anime RSS feed.",
|
||||
"url": "RSS Feed URL",
|
||||
"feed_name": "Feed Name",
|
||||
"test": "Test Feed",
|
||||
"test_failed": "Failed to fetch feed",
|
||||
"feed_title": "Feed Title",
|
||||
"item_count": "Items Found"
|
||||
},
|
||||
"media": {
|
||||
"title": "Media Library",
|
||||
"subtitle": "Configure the download and organization path.",
|
||||
"path": "Download Path",
|
||||
"path_hint": "Episodes will be organized into subdirectories automatically."
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Get notified about new episodes (optional).",
|
||||
"test": "Send Test",
|
||||
"test_failed": "Notification test failed"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Settings",
|
||||
"subtitle": "Confirm your configuration before completing setup.",
|
||||
"complete": "Complete Setup",
|
||||
"completing": "Setting up...",
|
||||
"success": "Setup complete! Please log in with your new credentials.",
|
||||
"failed": "Setup failed. Please try again."
|
||||
},
|
||||
"nav": {
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"skip": "Skip",
|
||||
"step": "Step {current} of {total}"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"calendar": "Calendar",
|
||||
"config": "Config",
|
||||
|
||||
@@ -240,6 +240,67 @@
|
||||
"subtitle": "从 RSS 添加番剧后即可查看每周放送时间"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"welcome": {
|
||||
"title": "欢迎使用 AutoBangumi",
|
||||
"subtitle": "让我们来设置你的自动追番系统。",
|
||||
"description": "本向导将引导你完成初始配置。你可以跳过可选步骤,稍后再进行配置。",
|
||||
"start": "开始设置"
|
||||
},
|
||||
"account": {
|
||||
"title": "创建账户",
|
||||
"subtitle": "为了安全,请修改默认登录凭证。",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirm_password": "确认密码",
|
||||
"password_mismatch": "两次输入的密码不一致",
|
||||
"password_too_short": "密码长度不能少于 8 个字符"
|
||||
},
|
||||
"downloader": {
|
||||
"title": "下载客户端",
|
||||
"subtitle": "连接到你的 qBittorrent 实例。",
|
||||
"test": "测试连接",
|
||||
"testing": "测试中...",
|
||||
"test_success": "连接成功",
|
||||
"test_failed": "连接失败"
|
||||
},
|
||||
"rss": {
|
||||
"title": "RSS 订阅源",
|
||||
"subtitle": "添加你的第一个番剧 RSS 源。",
|
||||
"url": "RSS 源地址",
|
||||
"feed_name": "源名称",
|
||||
"test": "测试订阅源",
|
||||
"test_failed": "获取订阅源失败",
|
||||
"feed_title": "源标题",
|
||||
"item_count": "条目数量"
|
||||
},
|
||||
"media": {
|
||||
"title": "媒体库",
|
||||
"subtitle": "配置下载和整理路径。",
|
||||
"path": "下载路径",
|
||||
"path_hint": "剧集将自动整理到子目录中。"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知设置",
|
||||
"subtitle": "获取新集数的通知推送(可选)。",
|
||||
"test": "发送测试",
|
||||
"test_failed": "通知测试失败"
|
||||
},
|
||||
"review": {
|
||||
"title": "确认设置",
|
||||
"subtitle": "在完成设置前确认你的配置。",
|
||||
"complete": "完成设置",
|
||||
"completing": "设置中...",
|
||||
"success": "设置完成!请使用新凭证登录。",
|
||||
"failed": "设置失败,请重试。"
|
||||
},
|
||||
"nav": {
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"skip": "跳过",
|
||||
"step": "第 {current} 步,共 {total} 步"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"calendar": "番剧日历",
|
||||
"config": "设置",
|
||||
|
||||
36
webui/src/pages/setup.vue
Normal file
36
webui/src/pages/setup.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
definePage({
|
||||
name: 'Setup',
|
||||
});
|
||||
|
||||
const setupStore = useSetupStore();
|
||||
const { currentStep, currentStepIndex, steps } = storeToRefs(setupStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-setup">
|
||||
<wizard-container
|
||||
:current-step="currentStepIndex"
|
||||
:total-steps="steps.length"
|
||||
>
|
||||
<wizard-step-welcome v-if="currentStep === 'welcome'" />
|
||||
<wizard-step-account v-else-if="currentStep === 'account'" />
|
||||
<wizard-step-downloader v-else-if="currentStep === 'downloader'" />
|
||||
<wizard-step-rss v-else-if="currentStep === 'rss'" />
|
||||
<wizard-step-media v-else-if="currentStep === 'media'" />
|
||||
<wizard-step-notification v-else-if="currentStep === 'notification'" />
|
||||
<wizard-step-review v-else-if="currentStep === 'review'" />
|
||||
</wizard-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-setup {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,35 @@ const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
let setupChecked = false;
|
||||
let needSetup = false;
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const { type, url } = storeToRefs(usePlayerStore());
|
||||
|
||||
if (!isLoggedIn.value && to.path !== '/login') {
|
||||
// Check setup status once per session
|
||||
if (!setupChecked && to.path !== '/setup') {
|
||||
try {
|
||||
const status = await apiSetup.getStatus();
|
||||
needSetup = status.need_setup;
|
||||
} catch {
|
||||
// If check fails, proceed normally
|
||||
}
|
||||
setupChecked = true;
|
||||
}
|
||||
|
||||
// Redirect to setup if needed
|
||||
if (needSetup && to.path !== '/setup') {
|
||||
return { name: 'Setup' };
|
||||
}
|
||||
|
||||
// Prevent going to setup after it's completed
|
||||
if (to.path === '/setup' && setupChecked && !needSetup) {
|
||||
return { name: 'Login' };
|
||||
}
|
||||
|
||||
if (!isLoggedIn.value && to.path !== '/login' && to.path !== '/setup') {
|
||||
return { name: 'Login' };
|
||||
} else if (isLoggedIn.value && to.path === '/login') {
|
||||
return { name: 'Index' };
|
||||
|
||||
149
webui/src/store/setup.ts
Normal file
149
webui/src/store/setup.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { SetupCompleteRequest, WizardStep } from '#/setup';
|
||||
|
||||
export const useSetupStore = defineStore('setup', () => {
|
||||
const steps: WizardStep[] = [
|
||||
'welcome',
|
||||
'account',
|
||||
'downloader',
|
||||
'rss',
|
||||
'media',
|
||||
'notification',
|
||||
'review',
|
||||
];
|
||||
|
||||
const currentStepIndex = ref(0);
|
||||
const currentStep = computed(() => steps[currentStepIndex.value]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Form data
|
||||
const accountData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const downloaderData = reactive({
|
||||
type: 'qbittorrent',
|
||||
host: '',
|
||||
username: '',
|
||||
password: '',
|
||||
path: '/downloads/Bangumi',
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
const rssData = reactive({
|
||||
url: '',
|
||||
name: '',
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
const mediaData = reactive({
|
||||
path: '/downloads/Bangumi',
|
||||
});
|
||||
|
||||
const notificationData = reactive({
|
||||
enable: false,
|
||||
type: 'telegram',
|
||||
token: '',
|
||||
chat_id: '',
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
// Validation states
|
||||
const validation = reactive({
|
||||
downloaderTested: false,
|
||||
rssTested: false,
|
||||
notificationTested: false,
|
||||
});
|
||||
|
||||
// Navigation
|
||||
function nextStep() {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStepIndex.value > 0) {
|
||||
currentStepIndex.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(index: number) {
|
||||
if (index >= 0 && index < steps.length) {
|
||||
currentStepIndex.value = index;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync media path with downloader path
|
||||
function syncMediaPath() {
|
||||
mediaData.path = downloaderData.path;
|
||||
}
|
||||
|
||||
// Build final request
|
||||
function buildCompleteRequest(): SetupCompleteRequest {
|
||||
return {
|
||||
username: accountData.username,
|
||||
password: accountData.password,
|
||||
downloader_type: downloaderData.type,
|
||||
downloader_host: downloaderData.host,
|
||||
downloader_username: downloaderData.username,
|
||||
downloader_password: downloaderData.password,
|
||||
downloader_path: mediaData.path,
|
||||
downloader_ssl: downloaderData.ssl,
|
||||
rss_url: rssData.skipped ? '' : rssData.url,
|
||||
rss_name: rssData.skipped ? '' : rssData.name,
|
||||
notification_enable: !notificationData.skipped && notificationData.enable,
|
||||
notification_type: notificationData.type,
|
||||
notification_token: notificationData.token,
|
||||
notification_chat_id: notificationData.chat_id,
|
||||
};
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
currentStepIndex.value = 0;
|
||||
isLoading.value = false;
|
||||
Object.assign(accountData, { username: '', password: '', confirmPassword: '' });
|
||||
Object.assign(downloaderData, {
|
||||
type: 'qbittorrent',
|
||||
host: '',
|
||||
username: '',
|
||||
password: '',
|
||||
path: '/downloads/Bangumi',
|
||||
ssl: false,
|
||||
});
|
||||
Object.assign(rssData, { url: '', name: '', skipped: false });
|
||||
Object.assign(mediaData, { path: '/downloads/Bangumi' });
|
||||
Object.assign(notificationData, {
|
||||
enable: false,
|
||||
type: 'telegram',
|
||||
token: '',
|
||||
chat_id: '',
|
||||
skipped: false,
|
||||
});
|
||||
Object.assign(validation, {
|
||||
downloaderTested: false,
|
||||
rssTested: false,
|
||||
notificationTested: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
isLoading,
|
||||
accountData,
|
||||
downloaderData,
|
||||
rssData,
|
||||
mediaData,
|
||||
notificationData,
|
||||
validation,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
syncMediaPath,
|
||||
buildCompleteRequest,
|
||||
$reset,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user