feat: add welcome feature with localized content and onboarding steps

This commit is contained in:
Soulter
2026-02-08 21:11:34 +08:00
parent dbe8e33c4b
commit 03e0949067
10 changed files with 438 additions and 6 deletions
-1
View File
@@ -33,7 +33,6 @@ tests/astrbot_plugin_openai
dashboard/node_modules/
dashboard/dist/
package-lock.json
package.json
yarn.lock
# Operating System
+1
View File
@@ -59,6 +59,7 @@ export class I18nLoader {
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
{ name: 'features/persona', path: 'features/persona.json' },
{ name: 'features/migration', path: 'features/migration.json' },
{ name: 'features/welcome', path: 'features/welcome.json' },
// 消息模块
{ name: 'messages/errors', path: 'messages/errors.json' },
@@ -1,4 +1,5 @@
{
"welcome": "Welcome",
"dashboard": "Dashboard",
"platforms": "Platforms",
"providers": "Providers",
@@ -0,0 +1,32 @@
{
"greeting": {
"morning": "Good morning, welcome to AstrBot",
"afternoon": "Good afternoon, welcome to AstrBot",
"evening": "Good evening, welcome to AstrBot",
"newYear": "Happy New Year!"
},
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
"step1Title": "Configure Platform Bot",
"step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
"step2Title": "Configure AI Model",
"step2Desc": "Configure AI models for AstrBot.",
"configure": "Configure",
"skip": "Skip",
"pending": "Pending",
"completed": "Completed",
"skipped": "Skipped",
"platformLoadFailed": "Failed to load platform configuration",
"providerLoadFailed": "Failed to load provider configuration",
"providerUpdateFailed": "Failed to update default chat provider in config file \"default\"",
"providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}"
},
"resources": {
"title": "Resources",
"githubDesc": "Give us a Star!",
"docsTitle": "Documentation",
"docsDesc": "Read the official AstrBot documentation."
}
}
@@ -1,4 +1,5 @@
{
"welcome": "欢迎",
"dashboard": "数据统计",
"platforms": "机器人",
"providers": "模型提供商",
@@ -0,0 +1,32 @@
{
"greeting": {
"morning": "上午好,欢迎使用 AstrBot",
"afternoon": "下午好,欢迎使用 AstrBot",
"evening": "晚上好,欢迎使用 AstrBot",
"newYear": "新年快乐!"
},
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
"onboard": {
"title": "快速引导",
"subtitle": "欢迎页可直接完成初始化。",
"step1Title": "配置平台机器人",
"step1Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。",
"step2Title": "配置 AI 模型",
"step2Desc": "为 AstrBot 配置 AI 模型。",
"configure": "去配置",
"skip": "跳过",
"pending": "待处理",
"completed": "已完成",
"skipped": "已跳过",
"platformLoadFailed": "加载平台配置失败",
"providerLoadFailed": "加载提供商配置失败",
"providerUpdateFailed": "更新 default 配置文件默认对话提供商失败",
"providerDefaultUpdated": "已将 default 配置文件的默认对话提供商设置为 {id}"
},
"resources": {
"title": "相关资源",
"githubDesc": "给 AstrBot 点个 Star 吧!",
"docsTitle": "文档",
"docsDesc": "查阅 AstrBot 的官方文档。"
}
}
+6 -2
View File
@@ -36,6 +36,7 @@ import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
import zhCNCommand from './locales/zh-CN/features/command.json';
import zhCNSubagent from './locales/zh-CN/features/subagent.json';
import zhCNWelcome from './locales/zh-CN/features/welcome.json';
import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
@@ -76,6 +77,7 @@ import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
import enUSCommand from './locales/en-US/features/command.json';
import enUSSubagent from './locales/en-US/features/subagent.json';
import enUSWelcome from './locales/en-US/features/welcome.json';
import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
@@ -123,7 +125,8 @@ export const translations = {
persona: zhCNPersona,
migration: zhCNMigration,
command: zhCNCommand,
subagent: zhCNSubagent
subagent: zhCNSubagent,
welcome: zhCNWelcome
},
messages: {
errors: zhCNErrors,
@@ -171,7 +174,8 @@ export const translations = {
persona: enUSPersona,
migration: enUSMigration,
command: enUSCommand,
subagent: enUSSubagent
subagent: enUSSubagent,
welcome: enUSWelcome
},
messages: {
errors: enUSErrors,
@@ -18,10 +18,15 @@ export interface menu {
// 在组件中使用时需要通过t()函数进行翻译
// 所有键名都使用 core.navigation.* 格式
const sidebarItem: menu[] = [
{
title: 'core.navigation.welcome',
icon: 'mdi-hand-wave-outline',
to: '/welcome',
},
{
title: 'core.navigation.platforms',
icon: 'mdi-robot',
to: '/',
to: '/platforms',
},
{
title: 'core.navigation.providers',
+7 -2
View File
@@ -3,13 +3,18 @@ const MainRoutes = {
meta: {
requiresAuth: true
},
redirect: '/main/platforms',
redirect: '/welcome',
component: () => import('@/layouts/full/FullLayout.vue'),
children: [
{
name: 'MainPage',
path: '/',
component: () => import('@/views/PlatformPage.vue')
component: () => import('@/views/WelcomePage.vue')
},
{
name: 'Welcome',
path: '/welcome',
component: () => import('@/views/WelcomePage.vue')
},
{
name: 'Extensions',
+352
View File
@@ -0,0 +1,352 @@
<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="6">
<!-- GitHub Card -->
<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="6">
<!-- Docs Card -->
<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-row>
</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 { useModuleI18n } from '@/i18n/composables';
import { useToast } from '@/utils/toast';
type StepState = 'pending' | 'completed' | 'skipped';
const { tm } = useModuleI18n('features/welcome');
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 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);
// start of day for comparison
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 }));
}
onMounted(async () => {
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;
}
</style>