| <template> |
| <div class="welcome-page"> |
| <v-container fluid class="pa-0"> |
| <v-row class="px-4 py-3 pb-6"> |
| <v-col cols="12"> |
| <h1 class="text-h1 font-weight-bold mb-2 d-flex align-center"> |
| {{ greetingText }} {{ greetingEmoji }} |
| </h1> |
| <p class="text-subtitle-1 text-medium-emphasis mb-0"> |
| {{ tm('subtitle') }} |
| </p> |
| </v-col> |
| </v-row> |
| |
| <v-row class="px-4"> |
| <v-col cols="12"> |
| <v-card class="welcome-card pa-6" elevation="0" border> |
| <div class="mb-4 text-h3 font-weight-bold"> |
| {{ tm('onboard.title') }} |
| </div> |
| |
| <v-timeline align="start" side="end" density="compact" class="welcome-timeline" truncate-line="both"> |
| <v-timeline-item :dot-color="platformStepState === 'completed' ? 'success' : 'primary'" |
| :icon="platformStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-1'" fill-dot size="small"> |
| <div class="pl-2"> |
| <div class="text-h6 font-weight-bold mb-1">{{ tm('onboard.step1Title') }}</div> |
| <p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step1Desc') }}</p> |
| <div class="d-flex align-center"> |
| <v-btn color="primary" variant="flat" rounded="pill" class="px-6" :loading="loadingPlatformDialog" |
| @click="openPlatformDialog"> |
| {{ tm('onboard.configure') }} |
| </v-btn> |
| <div v-if="platformStepState === 'completed'" |
| class="text-success d-flex align-center text-body-2 font-weight-medium ml-3"> |
| {{ tm('onboard.completed') }} |
| </div> |
| </div> |
| </div> |
| </v-timeline-item> |
| |
| <v-timeline-item :dot-color="providerStepState === 'completed' ? 'success' : 'primary'" |
| :icon="providerStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-2'" fill-dot size="small"> |
| <div class="pl-2"> |
| <div class="text-h6 font-weight-bold mb-1" |
| :class="{ 'text-medium-emphasis': platformStepState !== 'completed' }">{{ tm('onboard.step2Title') |
| }} |
| </div> |
| <p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step2Desc') }}</p> |
| <div class="d-flex align-center"> |
| <v-btn color="primary" variant="flat" rounded="pill" class="px-6" @click="openProviderDialog"> |
| {{ tm('onboard.configure') }} |
| </v-btn> |
| <div v-if="providerStepState === 'completed'" |
| class="text-success d-flex align-center text-body-2 font-weight-medium ml-3"> |
| {{ tm('onboard.completed') }} |
| </div> |
| </div> |
| </div> |
| </v-timeline-item> |
| </v-timeline> |
| </v-card> |
| |
| </v-col> |
| </v-row> |
| |
| <v-row class="px-4 mt-4"> |
| <v-col cols="12"> |
| <v-card class="welcome-card pa-6" elevation="0" border> |
| <div class="mb-4 text-h3 font-weight-bold"> |
| {{ tm('resources.title') }} |
| </div> |
| <v-row> |
| <v-col cols="12" sm="4"> |
| |
| <v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" |
| href="https://github.com/AstrBotDevs/AstrBot/" target="_blank"> |
| <div class="d-flex align-center mb-3"> |
| <v-icon size="32" class="mr-3">mdi-github</v-icon> |
| <span class="text-h6 font-weight-bold">GitHub</span> |
| </div> |
| <p class="text-body-2 text-medium-emphasis mb-0"> |
| {{ tm('resources.githubDesc') }} |
| </p> |
| </v-card> |
| </v-col> |
| |
| <v-col cols="12" sm="4"> |
| |
| <v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" href="https://docs.astrbot.app" |
| target="_blank"> |
| <div class="d-flex align-center mb-3"> |
| <v-icon size="32" class="mr-3">mdi-book-open-variant</v-icon> |
| <span class="text-h6 font-weight-bold">{{ tm('resources.docsTitle') }}</span> |
| </div> |
| <p class="text-body-2 text-medium-emphasis mb-0"> |
| {{ tm('resources.docsDesc') }} |
| </p> |
| </v-card> |
| </v-col> |
| |
| <v-col cols="12" sm="4"> |
| |
| <v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" |
| href="https://afdian.com/a/astrbot_team" target="_blank"> |
| <div class="d-flex align-center mb-3"> |
| <v-icon size="32" class="mr-3">mdi-hand-heart</v-icon> |
| <span class="text-h6 font-weight-bold">{{ tm('resources.afdianTitle') }}</span> |
| </div> |
| <p class="text-body-2 text-medium-emphasis mb-0"> |
| {{ tm('resources.afdianDesc') }} |
| </p> |
| </v-card> |
| </v-col> |
| |
| </v-row> |
| </v-card> |
| </v-col> |
| </v-row> |
| |
| <v-row v-if="showAnnouncement" class="px-4 mb-4"> |
| <v-col cols="12"> |
| <v-card class="welcome-card pa-6" elevation="0" border> |
| <div class="mb-4 text-h3 font-weight-bold"> |
| {{ tm('announcement.title') }} |
| </div> |
| <MarkdownRender |
| :content="welcomeAnnouncement" |
| :typewriter="false" |
| class="welcome-announcement-markdown markdown-content" |
| /> |
| </v-card> |
| </v-col> |
| </v-row> |
| </v-container> |
| |
| <AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData" |
| @refresh-config="loadPlatformConfigBase" /> |
| <ProviderConfigDialog v-model="showProviderDialog" /> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed, ref, watch, onMounted } from 'vue'; |
| import axios from 'axios'; |
| import AddNewPlatform from '@/components/platform/AddNewPlatform.vue'; |
| import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue'; |
| import { useI18n, useModuleI18n } from '@/i18n/composables'; |
| import { useToast } from '@/utils/toast'; |
| import { MarkdownRender } from 'markstream-vue'; |
| import 'markstream-vue/index.css'; |
| import 'highlight.js/styles/github.css'; |
| |
| type StepState = 'pending' | 'completed' | 'skipped'; |
| |
| const { tm } = useModuleI18n('features/welcome'); |
| const { locale } = useI18n(); |
| const { success: showSuccess, error: showError } = useToast(); |
| |
| const showAddPlatformDialog = ref(false); |
| const showProviderDialog = ref(false); |
| const loadingPlatformDialog = ref(false); |
| |
| const platformMetadata = ref<Record<string, any>>({}); |
| const platformConfigData = ref<Record<string, any>>({}); |
| const platformCountBeforeOpen = ref(0); |
| const providerCountBeforeOpen = ref(0); |
| |
| const platformStepState = ref<StepState>('pending'); |
| const providerStepState = ref<StepState>('pending'); |
| const welcomeAnnouncementRaw = ref<unknown>(null); |
| |
| function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) { |
| if (typeof raw === 'string') { |
| return raw.trim(); |
| } |
| |
| if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { |
| return ''; |
| } |
| |
| const localeMap = raw as Record<string, unknown>; |
| const normalized = currentLocale.replace('-', '_'); |
| const preferredKeys = |
| normalized.startsWith('zh') |
| ? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en'] |
| : [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh']; |
| |
| for (const key of preferredKeys) { |
| const value = localeMap[key]; |
| if (typeof value === 'string' && value.trim().length > 0) { |
| return value.trim(); |
| } |
| } |
| |
| return ''; |
| } |
| |
| const welcomeAnnouncement = computed(() => |
| resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value) |
| ); |
| const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0); |
| |
| const springFestivalDates: Record<number, string> = { |
| 2025: '01-29', |
| 2026: '02-17', |
| 2027: '02-06', |
| 2028: '01-26', |
| 2029: '02-13', |
| 2030: '02-03' |
| } |
| |
| function isSpringFestival() { |
| const now = new Date(); |
| const year = now.getFullYear(); |
| const dateStr = springFestivalDates[year]; |
| |
| if (!dateStr) return false; |
| |
| const [month, day] = dateStr.split('-').map(Number); |
| const festivalDate = new Date(year, month - 1, day); |
| |
| const start = new Date(festivalDate); |
| start.setDate(festivalDate.getDate() - 5); |
| |
| const end = new Date(festivalDate); |
| end.setDate(festivalDate.getDate() + 5); |
| |
| |
| const nowTime = now.setHours(0, 0, 0, 0); |
| const startTime = start.setHours(0, 0, 0, 0); |
| const endTime = end.setHours(0, 0, 0, 0); |
| |
| return nowTime >= startTime && nowTime <= endTime; |
| } |
| |
| function isExactSpringFestivalDay() { |
| const now = new Date(); |
| const year = now.getFullYear(); |
| const dateStr = springFestivalDates[year]; |
| |
| if (!dateStr) return false; |
| |
| const [month, day] = dateStr.split('-').map(Number); |
| const festivalDate = new Date(year, month - 1, day); |
| |
| const nowTime = new Date(now).setHours(0, 0, 0, 0); |
| const festivalTime = festivalDate.setHours(0, 0, 0, 0); |
| |
| return nowTime === festivalTime; |
| } |
| |
| const greetingEmoji = computed(() => { |
| if (isExactSpringFestivalDay()) { |
| return '🧨'; |
| } |
| const hour = new Date().getHours(); |
| if (hour >= 0 && hour < 5) { |
| return '😴'; |
| } |
| return '😊'; |
| }); |
| |
| const greetingText = computed(() => { |
| if (isSpringFestival()) { |
| return tm('greeting.newYear'); |
| } |
| const hour = new Date().getHours(); |
| if (hour < 12) return tm('greeting.morning'); |
| if (hour < 18) return tm('greeting.afternoon'); |
| return tm('greeting.evening'); |
| }); |
| |
| async function loadPlatformConfigBase() { |
| const res = await axios.get('/api/config/get'); |
| platformMetadata.value = res.data.data.metadata || {}; |
| platformConfigData.value = res.data.data.config || {}; |
| } |
| |
| function getChatProvidersFromTemplatePayload(payload: any) { |
| const providers = payload?.providers || []; |
| const sources = payload?.provider_sources || []; |
| const sourceMap = new Map(); |
| sources.forEach((s: any) => sourceMap.set(s.id, s.provider_type)); |
| |
| return providers.filter((provider: any) => { |
| if (provider.provider_type) { |
| return provider.provider_type === 'chat_completion'; |
| } |
| if (provider.provider_source_id) { |
| const type = sourceMap.get(provider.provider_source_id); |
| if (type === 'chat_completion') return true; |
| } |
| return String(provider.type || '').includes('chat_completion'); |
| }); |
| } |
| |
| async function fetchChatProviders() { |
| const response = await axios.get('/api/config/provider/template'); |
| if (response.data.status !== 'ok') { |
| throw new Error(response.data.message || tm('onboard.providerLoadFailed')); |
| } |
| return getChatProvidersFromTemplatePayload(response.data.data); |
| } |
| |
| function pickDefaultProviderId(providers: any[]) { |
| if (!providers.length) return ''; |
| const enabledProvider = providers.find((provider) => provider.enable !== false); |
| return (enabledProvider || providers[0]).id || ''; |
| } |
| |
| async function syncDefaultConfigProviderIfNeeded() { |
| const providers = await fetchChatProviders(); |
| if (!providers.length) return; |
| |
| const targetProviderId = pickDefaultProviderId(providers); |
| if (!targetProviderId) return; |
| |
| const configRes = await axios.get('/api/config/abconf', { params: { id: 'default' } }); |
| const configData = configRes.data?.data?.config || {}; |
| if (!configData.provider_settings) { |
| configData.provider_settings = {}; |
| } |
| |
| if (configData.provider_settings.default_provider_id === targetProviderId) return; |
| |
| configData.provider_settings.default_provider_id = targetProviderId; |
| |
| const updateRes = await axios.post('/api/config/astrbot/update', { |
| conf_id: 'default', |
| config: configData |
| }); |
| if (updateRes.data.status !== 'ok') { |
| throw new Error(updateRes.data.message || tm('onboard.providerUpdateFailed')); |
| } |
| |
| showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId })); |
| } |
| |
| async function loadWelcomeAnnouncement() { |
| try { |
| const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement'); |
| welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null; |
| } catch (e) { |
| welcomeAnnouncementRaw.value = null; |
| console.error(e); |
| } |
| } |
| |
| onMounted(async () => { |
| await loadWelcomeAnnouncement(); |
| |
| try { |
| await loadPlatformConfigBase(); |
| if ((platformConfigData.value.platform || []).length > 0) { |
| platformStepState.value = 'completed'; |
| } |
| } catch (e) { |
| console.error(e); |
| } |
| |
| try { |
| const providers = await fetchChatProviders(); |
| if (providers.length > 0) { |
| providerStepState.value = 'completed'; |
| } |
| } catch (e) { |
| console.error(e); |
| } |
| }); |
| |
| async function openPlatformDialog() { |
| loadingPlatformDialog.value = true; |
| try { |
| await loadPlatformConfigBase(); |
| platformCountBeforeOpen.value = (platformConfigData.value.platform || []).length; |
| showAddPlatformDialog.value = true; |
| } catch (err: any) { |
| showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed')); |
| } finally { |
| loadingPlatformDialog.value = false; |
| } |
| } |
| |
| async function openProviderDialog() { |
| try { |
| const providers = await fetchChatProviders(); |
| providerCountBeforeOpen.value = providers.length; |
| showProviderDialog.value = true; |
| } catch (err: any) { |
| showError(err?.response?.data?.message || err?.message || tm('onboard.providerLoadFailed')); |
| } |
| } |
| |
| watch(showAddPlatformDialog, async (visible, wasVisible) => { |
| if (!wasVisible || visible) return; |
| try { |
| await loadPlatformConfigBase(); |
| const newCount = (platformConfigData.value.platform || []).length; |
| if (newCount > platformCountBeforeOpen.value) { |
| platformStepState.value = 'completed'; |
| } |
| } catch (err: any) { |
| showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed')); |
| } |
| }); |
| |
| watch(showProviderDialog, async (visible, wasVisible) => { |
| if (!wasVisible || visible) return; |
| try { |
| const providers = await fetchChatProviders(); |
| if (providers.length > providerCountBeforeOpen.value) { |
| providerStepState.value = 'completed'; |
| await syncDefaultConfigProviderIfNeeded(); |
| } |
| } catch (err: any) { |
| showError(err?.response?.data?.message || err?.message || tm('onboard.providerUpdateFailed')); |
| } |
| }); |
| </script> |
| |
| <style scoped> |
| .welcome-page { |
| height: 100%; |
| } |
| |
| .welcome-card { |
| border-radius: 16px; |
| } |
| |
| .welcome-announcement-markdown { |
| line-height: 1.7; |
| } |
| </style> |
| |