feat(frontend): 实现新手引导功能
- 添加 Guide 组件和引导步骤配置 - 实现 useOnboardingTour 和 useTourStepDescription composables - 添加 onboarding store 管理引导状态 - 更新多个视图和组件以支持引导功能 - 添加国际化支持(中英文) - 删除旧的实现指南文档
BIN
.gemini-clipboard/clipboard-1766952484581.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
.gemini-clipboard/clipboard-1766952752713.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
.gemini-clipboard/clipboard-1766952878583.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
.gemini-clipboard/clipboard-1766952945800.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
.gemini-clipboard/clipboard-1766953717992.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
.gemini-clipboard/clipboard-1766954243984.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -14,6 +14,7 @@
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"driver.js": "^1.4.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
|
||||
1962
frontend/pnpm-lock.yaml
generated
Normal file
157
frontend/src/components/Guide/TourDescription.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="tour-description">
|
||||
<!-- 主要段落 -->
|
||||
<p v-if="mainText" class="main-text">{{ mainText }}</p>
|
||||
|
||||
<!-- 特性列表 -->
|
||||
<div v-if="features && features.length > 0" class="features-section">
|
||||
<p v-if="featuresTitle" class="section-title">{{ featuresTitle }}</p>
|
||||
<ul class="features-list">
|
||||
<li v-for="(feature, index) in features" :key="index">
|
||||
<span v-if="feature.icon" class="feature-icon">{{ feature.icon }}</span>
|
||||
<span v-if="feature.label" class="feature-label">{{ feature.label }}</span>
|
||||
<span class="feature-text">{{ feature.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<div v-if="tip" :class="['tip-box', `tip-${tip.type || 'info'}`]">
|
||||
<span v-if="tip.label" class="tip-label">{{ tip.label }}</span>
|
||||
<div v-if="tip.text" class="tip-text">{{ tip.text }}</div>
|
||||
<ul v-if="tip.items && tip.items.length > 0" class="tip-list">
|
||||
<li v-for="(item, index) in tip.items" :key="index">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 行动提示 -->
|
||||
<p v-if="action" class="action-text">{{ action }}</p>
|
||||
|
||||
<!-- 额外说明 -->
|
||||
<p v-if="note" class="note-text">{{ note }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface TourFeature {
|
||||
icon?: string
|
||||
label?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TourTip {
|
||||
type?: 'info' | 'success' | 'warning' | 'example'
|
||||
label?: string
|
||||
text?: string
|
||||
items?: string[]
|
||||
}
|
||||
|
||||
export interface TourDescriptionProps {
|
||||
mainText?: string
|
||||
featuresTitle?: string
|
||||
features?: TourFeature[]
|
||||
tip?: TourTip
|
||||
action?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
defineProps<TourDescriptionProps>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tour-description {
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-text {
|
||||
margin-bottom: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.features-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.features-list {
|
||||
margin-left: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.features-list li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.feature-label {
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tip-info {
|
||||
background: #eff6ff;
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.tip-success {
|
||||
background: #f0fdf4;
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.tip-warning {
|
||||
background: #fef3c7;
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.tip-example {
|
||||
background: #f0fdf4;
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.tip-label {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tip-list {
|
||||
margin: 8px 0 0 16px;
|
||||
}
|
||||
|
||||
.tip-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
margin-top: 12px;
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section
|
||||
class="tour-step-description"
|
||||
:lang="locale"
|
||||
:data-step-key="stepKey"
|
||||
>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TourStepKey } from '@/composables/useTourStepDescription'
|
||||
|
||||
interface TourStepDescriptionProps {
|
||||
stepKey: TourStepKey
|
||||
locale: string
|
||||
}
|
||||
|
||||
defineProps<TourStepDescriptionProps>()
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TourStepDescription } from './TourStepDescription.vue'
|
||||
289
frontend/src/components/Guide/steps.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { DriveStep } from 'driver.js'
|
||||
|
||||
/**
|
||||
* 管理员完整引导流程
|
||||
* 交互式引导:指引用户实际操作
|
||||
*/
|
||||
export const getAdminSteps = (t: (key: string) => string): DriveStep[] => [
|
||||
// ========== 欢迎介绍 ==========
|
||||
{
|
||||
popover: {
|
||||
title: t('onboarding.admin.welcome.title'),
|
||||
description: t('onboarding.admin.welcome.description'),
|
||||
align: 'center',
|
||||
nextBtnText: t('onboarding.admin.welcome.nextBtn'),
|
||||
prevBtnText: t('onboarding.admin.welcome.prevBtn')
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 第一部分:创建分组 ==========
|
||||
{
|
||||
element: '#sidebar-group-manage',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupManage.title'),
|
||||
description: t('onboarding.admin.groupManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close'],
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="groups-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.createGroup.title'),
|
||||
description: t('onboarding.admin.createGroup.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupName.title'),
|
||||
description: t('onboarding.admin.groupName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-platform"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupPlatform.title'),
|
||||
description: t('onboarding.admin.groupPlatform.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-multiplier"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupMultiplier.title'),
|
||||
description: t('onboarding.admin.groupMultiplier.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-exclusive"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupExclusive.title'),
|
||||
description: t('onboarding.admin.groupExclusive.description'),
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="group-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.groupSubmit.title'),
|
||||
description: t('onboarding.admin.groupSubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 第二部分:创建账号授权 ==========
|
||||
{
|
||||
element: '#sidebar-channel-manage',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountManage.title'),
|
||||
description: t('onboarding.admin.accountManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="accounts-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.createAccount.title'),
|
||||
description: t('onboarding.admin.createAccount.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountName.title'),
|
||||
description: t('onboarding.admin.accountName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-platform"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountPlatform.title'),
|
||||
description: t('onboarding.admin.accountPlatform.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-type"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountType.title'),
|
||||
description: t('onboarding.admin.accountType.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-priority"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountPriority.title'),
|
||||
description: t('onboarding.admin.accountPriority.description'),
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-groups"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountGroups.title'),
|
||||
description: t('onboarding.admin.accountGroups.description'),
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="account-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.accountSubmit.title'),
|
||||
description: t('onboarding.admin.accountSubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 第三部分:创建API密钥 ==========
|
||||
{
|
||||
element: '[data-tour="sidebar-my-keys"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keyManage.title'),
|
||||
description: t('onboarding.admin.keyManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="keys-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.createKey.title'),
|
||||
description: t('onboarding.admin.createKey.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keyName.title'),
|
||||
description: t('onboarding.admin.keyName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-group"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keyGroup.title'),
|
||||
description: t('onboarding.admin.keyGroup.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.admin.keySubmit.title'),
|
||||
description: t('onboarding.admin.keySubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 普通用户引导流程
|
||||
*/
|
||||
export const getUserSteps = (t: (key: string) => string): DriveStep[] => [
|
||||
{
|
||||
popover: {
|
||||
title: t('onboarding.user.welcome.title'),
|
||||
description: t('onboarding.user.welcome.description'),
|
||||
align: 'center',
|
||||
nextBtnText: t('onboarding.user.welcome.nextBtn'),
|
||||
prevBtnText: t('onboarding.user.welcome.prevBtn')
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="sidebar-my-keys"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keyManage.title'),
|
||||
description: t('onboarding.user.keyManage.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="keys-create-btn"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.createKey.title'),
|
||||
description: t('onboarding.user.createKey.description'),
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-name"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keyName.title'),
|
||||
description: t('onboarding.user.keyName.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-group"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keyGroup.title'),
|
||||
description: t('onboarding.user.keyGroup.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: ['close']
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '[data-tour="key-form-submit"]',
|
||||
popover: {
|
||||
title: t('onboarding.user.keySubmit.title'),
|
||||
description: t('onboarding.user.keySubmit.description'),
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
showButtons: ['close']
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -53,13 +53,14 @@
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.enterAccountName')"
|
||||
data-tour="account-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Platform Selection - Segmented Control Style -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.platform') }}</label>
|
||||
<div class="mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
||||
<div class="mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700" data-tour="account-form-platform">
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'anthropic'"
|
||||
@@ -141,7 +142,7 @@
|
||||
<!-- Account Type Selection (Anthropic) -->
|
||||
<div v-if="form.platform === 'anthropic'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
@@ -231,7 +232,7 @@
|
||||
<!-- Account Type Selection (OpenAI) -->
|
||||
<div v-if="form.platform === 'openai'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
@@ -313,7 +314,7 @@
|
||||
<!-- Account Type Selection (Gemini) -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
@@ -959,18 +960,21 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
|
||||
<input v-model.number="form.priority" type="number" min="1" class="input" />
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
data-tour="account-form-priority"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Selection - 仅标准模式显示 -->
|
||||
<GroupSelector
|
||||
v-if="!authStore.isSimpleMode"
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="form.platform"
|
||||
/>
|
||||
<div v-if="!authStore.isSimpleMode" data-tour="account-form-groups">
|
||||
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -1005,6 +1009,7 @@
|
||||
form="create-account-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="account-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.name') }}</label>
|
||||
<input v-model="form.name" type="text" required class="input" />
|
||||
<input v-model="form.name" type="text" required class="input" data-tour="edit-account-form-name" />
|
||||
</div>
|
||||
|
||||
<!-- API Key fields (only for apikey type) -->
|
||||
@@ -457,7 +457,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
|
||||
<input v-model.number="form.priority" type="number" min="1" class="input" />
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
data-tour="account-form-priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -467,12 +473,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Group Selection - 仅标准模式显示 -->
|
||||
<GroupSelector
|
||||
v-if="!authStore.isSimpleMode"
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="account?.platform"
|
||||
/>
|
||||
<div v-if="!authStore.isSimpleMode" data-tour="account-form-groups">
|
||||
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -486,6 +489,7 @@
|
||||
form="edit-account-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="account-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
|
||||
@@ -200,6 +200,14 @@
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 py-1 dark:border-dark-700">
|
||||
<button @click="handleReplayGuide" class="dropdown-item w-full">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 14a1 1 0 110 2 1 1 0 010-2zm1.07-7.75c0-.6-.49-1.25-1.32-1.25-.7 0-1.22.4-1.43 1.02a1 1 0 11-1.9-.62A3.41 3.41 0 0111.8 5c2.02 0 3.25 1.4 3.25 2.9 0 2-1.83 2.55-2.43 3.12-.43.4-.47.75-.47 1.23a1 1 0 01-2 0c0-1 .16-1.82 1.1-2.7.69-.64 1.82-1.05 1.82-2.06z"
|
||||
/>
|
||||
</svg>
|
||||
{{ $t('onboarding.restartTour') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="dropdown-item w-full text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
@@ -232,7 +240,7 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
|
||||
@@ -241,6 +249,7 @@ const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const dropdownOpen = ref(false)
|
||||
@@ -300,6 +309,11 @@ async function handleLogout() {
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
function handleReplayGuide() {
|
||||
closeDropdown()
|
||||
onboardingStore.replay()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
closeDropdown()
|
||||
|
||||
@@ -23,11 +23,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import '@/styles/onboarding.css'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useOnboardingTour } from '@/composables/useOnboardingTour'
|
||||
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const { t } = useI18n()
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
const { replayTour } = useOnboardingTour({
|
||||
steps: isAdmin.value ? getAdminSteps(t) : getUserSteps(t),
|
||||
storageKey: isAdmin.value ? 'admin_guide' : 'user_guide',
|
||||
autoStart: true
|
||||
})
|
||||
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
onMounted(() => {
|
||||
onboardingStore.setReplayCallback(replayTour)
|
||||
})
|
||||
|
||||
defineExpose({ replayTour })
|
||||
</script>
|
||||
|
||||
@@ -36,7 +36,16 @@
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path) }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
:id="
|
||||
item.path === '/admin/accounts'
|
||||
? 'sidebar-channel-manage'
|
||||
: item.path === '/admin/groups'
|
||||
? 'sidebar-group-manage'
|
||||
: item.path === '/admin/redeem'
|
||||
? 'sidebar-wallet'
|
||||
: undefined
|
||||
"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
@@ -59,7 +68,8 @@
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path) }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
@@ -79,7 +89,8 @@
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path) }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
@@ -136,7 +147,7 @@
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -144,6 +155,7 @@ const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const mobileOpen = computed(() => appStore.mobileOpen)
|
||||
@@ -465,12 +477,24 @@ function closeMobile() {
|
||||
appStore.setMobileOpen(false)
|
||||
}
|
||||
|
||||
function handleMenuItemClick() {
|
||||
function handleMenuItemClick(itemPath: string) {
|
||||
if (mobileOpen.value) {
|
||||
setTimeout(() => {
|
||||
appStore.setMobileOpen(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// Map paths to tour selectors
|
||||
const pathToSelector: Record<string, string> = {
|
||||
'/admin/groups': '#sidebar-group-manage',
|
||||
'/admin/accounts': '#sidebar-channel-manage',
|
||||
'/keys': '[data-tour="sidebar-my-keys"]'
|
||||
}
|
||||
|
||||
const selector = pathToSelector[itemPath]
|
||||
if (selector && onboardingStore.isCurrentStep(selector)) {
|
||||
onboardingStore.nextStep(500)
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
|
||||
639
frontend/src/composables/useOnboardingTour.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import { onBeforeUnmount, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { driver, type Driver, type DriveStep } from 'driver.js'
|
||||
import 'driver.js/dist/driver.css'
|
||||
import { useAuthStore as useUserStore } from '@/stores/auth'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getAdminSteps, getUserSteps } from '@/components/Guide/steps'
|
||||
|
||||
export interface OnboardingOptions {
|
||||
steps: DriveStep[]
|
||||
storageKey?: string
|
||||
autoStart?: boolean
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function useOnboardingTour(options: OnboardingOptions) {
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const storageVersion = 'v4_interactive' // Bump version for new tour type
|
||||
|
||||
// Timing constants for better maintainability
|
||||
const TIMING = {
|
||||
INTERACTIVE_WAIT_MS: 800, // Default wait time for interactive steps
|
||||
SELECT_WAIT_MS: 2500, // Extended wait for Select components
|
||||
ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection
|
||||
AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour
|
||||
} as const
|
||||
|
||||
// 使用 store 管理的全局 driver 实例
|
||||
let driverInstance: Driver | null = onboardingStore.getDriverInstance()
|
||||
let currentClickListener: {
|
||||
element: HTMLElement
|
||||
handler: () => void
|
||||
keyHandler?: (e: KeyboardEvent) => void
|
||||
originalTabIndex?: string | null
|
||||
eventTypes?: string[] // Track which event types were added
|
||||
} | null = null
|
||||
let autoStartTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let globalKeyboardHandler: ((e: KeyboardEvent) => void) | null = null
|
||||
|
||||
const getStorageKey = () => {
|
||||
const baseKey = options.storageKey ?? 'onboarding_tour'
|
||||
const userId = userStore.user?.id ?? 'guest'
|
||||
const role = userStore.user?.role ?? 'user'
|
||||
return `${baseKey}_${userId}_${role}_${storageVersion}`
|
||||
}
|
||||
|
||||
const hasSeen = () => {
|
||||
return localStorage.getItem(getStorageKey()) === 'true'
|
||||
}
|
||||
|
||||
const markAsSeen = () => {
|
||||
localStorage.setItem(getStorageKey(), 'true')
|
||||
}
|
||||
|
||||
const clearSeen = () => {
|
||||
localStorage.removeItem(getStorageKey())
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否存在,如果不存在则重试
|
||||
*/
|
||||
const ensureElement = async (selector: string, timeout = 5000): Promise<boolean> => {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const element = document.querySelector(selector)
|
||||
if (element && element.getBoundingClientRect().height > 0) {
|
||||
return true
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const startTour = async (startIndex = 0) => {
|
||||
// 动态获取当前用户角色和步骤
|
||||
const isAdmin = userStore.user?.role === 'admin'
|
||||
const steps = isAdmin ? getAdminSteps(t) : getUserSteps(t)
|
||||
|
||||
// 确保 DOM 就绪
|
||||
await nextTick()
|
||||
|
||||
// 如果指定了起始步骤,确保元素可见
|
||||
const currentStep = steps[startIndex]
|
||||
if (currentStep?.element && typeof currentStep.element === 'string') {
|
||||
await ensureElement(currentStep.element, TIMING.ELEMENT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
if (driverInstance) {
|
||||
driverInstance.destroy()
|
||||
}
|
||||
|
||||
// 创建新的 driver 实例并存储到 store
|
||||
driverInstance = driver({
|
||||
showProgress: true,
|
||||
steps,
|
||||
animate: true,
|
||||
allowClose: false, // 禁止点击遮罩关闭
|
||||
stagePadding: 4,
|
||||
popoverClass: 'theme-tour-popover',
|
||||
nextBtnText: t('common.next'),
|
||||
prevBtnText: t('common.back'),
|
||||
doneBtnText: t('common.confirm'),
|
||||
|
||||
// 导航处理
|
||||
onNextClick: async (_el, _step, { config, state }) => {
|
||||
// 如果是最后一步,点击则是"完成"
|
||||
if (state.activeIndex === (config.steps?.length ?? 0) - 1) {
|
||||
markAsSeen()
|
||||
driverInstance?.destroy()
|
||||
onboardingStore.setDriverInstance(null)
|
||||
} else {
|
||||
// 注意:交互式步骤通常隐藏 Next 按钮,此处逻辑为防御性编程
|
||||
const currentIndex = state.activeIndex ?? 0
|
||||
const currentStep = steps[currentIndex]
|
||||
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
|
||||
currentStep?.popover.showButtons[0] === 'close'
|
||||
|
||||
if (isInteractiveStep && currentStep.element) {
|
||||
const targetElement = typeof currentStep.element === 'string'
|
||||
? document.querySelector(currentStep.element) as HTMLElement
|
||||
: currentStep.element as HTMLElement
|
||||
|
||||
if (targetElement) {
|
||||
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
||||
if (isClickable) {
|
||||
targetElement.click()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
driverInstance?.moveNext()
|
||||
}
|
||||
},
|
||||
onPrevClick: () => {
|
||||
driverInstance?.movePrevious()
|
||||
},
|
||||
onCloseClick: () => {
|
||||
if (confirm(t('onboarding.confirmExit'))) {
|
||||
driverInstance?.destroy()
|
||||
onboardingStore.setDriverInstance(null)
|
||||
}
|
||||
},
|
||||
|
||||
// 渲染时重组 Footer 布局
|
||||
onPopoverRender: (popover, { config, state }) => {
|
||||
// Class name constants for easier maintenance
|
||||
const CLASS_REORGANIZED = 'reorganized'
|
||||
const CLASS_FOOTER_LEFT = 'footer-left'
|
||||
const CLASS_FOOTER_RIGHT = 'footer-right'
|
||||
const CLASS_SKIP_BTN = 'header-skip-btn'
|
||||
const CLASS_DONE_BTN = 'driver-popover-done-btn'
|
||||
const CLASS_TITLE_TEXT = 'driver-popover-title-text'
|
||||
const CLASS_PROGRESS_TEXT = 'driver-popover-progress-text'
|
||||
const CLASS_NEXT_BTN = 'driver-popover-next-btn'
|
||||
const CLASS_PREV_BTN = 'driver-popover-prev-btn'
|
||||
|
||||
try {
|
||||
const { title: titleEl, footer: footerEl, nextButton, previousButton } = popover
|
||||
|
||||
// Defensive check: ensure popover elements exist
|
||||
if (!titleEl || !footerEl) {
|
||||
console.warn('Onboarding: Missing popover elements')
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 顶部:添加 "不再提示" 按钮
|
||||
if (!titleEl.querySelector(`.${CLASS_SKIP_BTN}`)) {
|
||||
const titleText = titleEl.innerText
|
||||
if (!titleEl.querySelector(`.${CLASS_TITLE_TEXT}`)) {
|
||||
const titleSpan = document.createElement('span')
|
||||
titleSpan.className = CLASS_TITLE_TEXT
|
||||
titleSpan.textContent = titleText
|
||||
titleEl.textContent = ''
|
||||
titleEl.appendChild(titleSpan)
|
||||
}
|
||||
|
||||
const skipBtn = document.createElement('button')
|
||||
skipBtn.className = CLASS_SKIP_BTN
|
||||
skipBtn.innerText = t('onboarding.dontShowAgain')
|
||||
skipBtn.title = t('onboarding.dontShowAgainTitle')
|
||||
skipBtn.type = 'button'
|
||||
skipBtn.setAttribute('aria-label', t('onboarding.dontShowAgain'))
|
||||
skipBtn.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(t('onboarding.confirmDontShow'))) {
|
||||
markAsSeen()
|
||||
driverInstance?.destroy()
|
||||
onboardingStore.setDriverInstance(null)
|
||||
}
|
||||
}
|
||||
titleEl.appendChild(skipBtn)
|
||||
}
|
||||
|
||||
// 1.5 交互式步骤提示
|
||||
const currentStep = steps[state.activeIndex ?? 0]
|
||||
const isInteractive = currentStep?.popover?.showButtons?.length === 1 &&
|
||||
currentStep?.popover?.showButtons[0] === 'close'
|
||||
|
||||
if (isInteractive && popover.description) {
|
||||
const hintClass = 'driver-popover-description-hint'
|
||||
if (!popover.description.querySelector(`.${hintClass}`)) {
|
||||
const hint = document.createElement('div')
|
||||
hint.className = `${hintClass} mt-2 text-xs text-gray-500 flex items-center gap-1`
|
||||
|
||||
const iconSpan = document.createElement('span')
|
||||
iconSpan.className = 'i-mdi-keyboard-return mr-1'
|
||||
|
||||
const textNode = document.createTextNode(
|
||||
t('onboarding.interactiveHint', 'Press Enter or Click to continue'),
|
||||
)
|
||||
|
||||
hint.appendChild(iconSpan)
|
||||
hint.appendChild(textNode)
|
||||
popover.description.appendChild(hint)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 底部:DOM 重组
|
||||
if (!footerEl.classList.contains(CLASS_REORGANIZED)) {
|
||||
footerEl.classList.add(CLASS_REORGANIZED)
|
||||
|
||||
const progressEl = footerEl.querySelector(`.${CLASS_PROGRESS_TEXT}`)
|
||||
const nextBtnEl = nextButton || footerEl.querySelector(`.${CLASS_NEXT_BTN}`)
|
||||
const prevBtnEl = previousButton || footerEl.querySelector(`.${CLASS_PREV_BTN}`)
|
||||
|
||||
const leftContainer = document.createElement('div')
|
||||
leftContainer.className = CLASS_FOOTER_LEFT
|
||||
|
||||
const rightContainer = document.createElement('div')
|
||||
rightContainer.className = CLASS_FOOTER_RIGHT
|
||||
|
||||
if (progressEl) leftContainer.appendChild(progressEl)
|
||||
|
||||
const shortcutsEl = document.createElement('div')
|
||||
shortcutsEl.className = 'footer-shortcuts'
|
||||
|
||||
const shortcut1 = document.createElement('span')
|
||||
shortcut1.className = 'shortcut-item'
|
||||
const kbd1 = document.createElement('kbd')
|
||||
kbd1.textContent = '←'
|
||||
const kbd2 = document.createElement('kbd')
|
||||
kbd2.textContent = '→'
|
||||
shortcut1.appendChild(kbd1)
|
||||
shortcut1.appendChild(kbd2)
|
||||
shortcut1.appendChild(
|
||||
document.createTextNode(` ${t('onboarding.navigation.flipPage')}`),
|
||||
)
|
||||
|
||||
const shortcut2 = document.createElement('span')
|
||||
shortcut2.className = 'shortcut-item'
|
||||
const kbd3 = document.createElement('kbd')
|
||||
kbd3.textContent = 'ESC'
|
||||
shortcut2.appendChild(kbd3)
|
||||
shortcut2.appendChild(
|
||||
document.createTextNode(` ${t('onboarding.navigation.exit')}`),
|
||||
)
|
||||
|
||||
shortcutsEl.appendChild(shortcut1)
|
||||
shortcutsEl.appendChild(shortcut2)
|
||||
leftContainer.appendChild(shortcutsEl)
|
||||
|
||||
if (prevBtnEl) rightContainer.appendChild(prevBtnEl)
|
||||
if (nextBtnEl) rightContainer.appendChild(nextBtnEl)
|
||||
|
||||
footerEl.innerHTML = ''
|
||||
footerEl.appendChild(leftContainer)
|
||||
footerEl.appendChild(rightContainer)
|
||||
}
|
||||
|
||||
// 3. 状态更新
|
||||
const isLastStep = state.activeIndex === (config.steps?.length ?? 0) - 1
|
||||
const activeNextBtn = nextButton || footerEl.querySelector(`.${CLASS_NEXT_BTN}`)
|
||||
|
||||
if (activeNextBtn) {
|
||||
if (isLastStep) {
|
||||
activeNextBtn.classList.add(CLASS_DONE_BTN)
|
||||
} else {
|
||||
activeNextBtn.classList.remove(CLASS_DONE_BTN)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Onboarding Tour Render Error:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 步骤高亮时触发
|
||||
onHighlightStarted: async (element, step) => {
|
||||
// 清理之前的监听器
|
||||
if (currentClickListener) {
|
||||
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
|
||||
// Remove all tracked event types
|
||||
if (eventTypes) {
|
||||
eventTypes.forEach(type => el.removeEventListener(type, handler))
|
||||
}
|
||||
if (keyHandler) el.removeEventListener('keydown', keyHandler)
|
||||
if (originalTabIndex !== undefined) {
|
||||
if (originalTabIndex === null) el.removeAttribute('tabindex')
|
||||
else el.setAttribute('tabindex', originalTabIndex)
|
||||
}
|
||||
currentClickListener = null
|
||||
}
|
||||
|
||||
// 尝试等待元素
|
||||
if (!element && step.element && typeof step.element === 'string') {
|
||||
const exists = await ensureElement(step.element, 8000)
|
||||
if (!exists) {
|
||||
console.warn(`Tour element not found after 8s: ${step.element}`)
|
||||
return
|
||||
}
|
||||
element = document.querySelector(step.element) as HTMLElement
|
||||
}
|
||||
|
||||
const isInteractiveStep = step.popover?.showButtons?.length === 1 &&
|
||||
step.popover.showButtons[0] === 'close'
|
||||
|
||||
if (isInteractiveStep && element) {
|
||||
const htmlElement = element as HTMLElement
|
||||
|
||||
// Check if this is a submit button - if so, don't bind auto-advance listeners
|
||||
// Let business code (e.g., handleCreateGroup) manually call nextStep after success
|
||||
const isSubmitButton = htmlElement.getAttribute('type') === 'submit' ||
|
||||
(htmlElement.tagName === 'BUTTON' && htmlElement.closest('form'))
|
||||
|
||||
if (isSubmitButton) {
|
||||
console.log('Submit button detected, skipping auto-advance listener')
|
||||
return // Don't bind any click listeners for submit buttons
|
||||
}
|
||||
|
||||
const originalTabIndex = htmlElement.getAttribute('tabindex')
|
||||
if (!htmlElement.isContentEditable && htmlElement.tabIndex === -1) {
|
||||
htmlElement.setAttribute('tabindex', '0')
|
||||
}
|
||||
|
||||
// Enhanced Select component detection - check both children and self
|
||||
const isSelectComponent = htmlElement.querySelector('.select-trigger') !== null ||
|
||||
htmlElement.classList.contains('select-trigger')
|
||||
|
||||
// Single-execution protection flag
|
||||
let hasExecuted = false
|
||||
|
||||
const clickHandler = async () => {
|
||||
// Prevent duplicate execution
|
||||
if (hasExecuted) {
|
||||
console.warn('Click handler already executed, skipping')
|
||||
return
|
||||
}
|
||||
hasExecuted = true
|
||||
|
||||
// For Select components, wait longer to allow user to make a selection
|
||||
const waitTime = isSelectComponent ? TIMING.SELECT_WAIT_MS : TIMING.INTERACTIVE_WAIT_MS
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime))
|
||||
|
||||
// Verify driver is still active and not destroyed
|
||||
if (!driverInstance || !driverInstance.isActive()) {
|
||||
console.warn('Driver instance destroyed or inactive during navigation')
|
||||
return
|
||||
}
|
||||
|
||||
const currentIndex = driverInstance.getActiveIndex() ?? 0
|
||||
const nextStep = steps[currentIndex + 1]
|
||||
|
||||
if (nextStep?.element && typeof nextStep.element === 'string') {
|
||||
// 增加超时时间到 8 秒,给路由导航更多时间
|
||||
const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS)
|
||||
if (!exists) {
|
||||
console.warn('Next step element not found after timeout, aborting auto-advance')
|
||||
console.warn('Expected element:', nextStep.element)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Final check before moving
|
||||
if (driverInstance && driverInstance.isActive()) {
|
||||
driverInstance.moveNext()
|
||||
}
|
||||
}
|
||||
|
||||
// For input fields, advance on input/change events instead of click
|
||||
const isInputField = ['INPUT', 'TEXTAREA', 'SELECT'].includes(htmlElement.tagName)
|
||||
|
||||
if (isInputField) {
|
||||
const inputHandler = () => {
|
||||
// Remove listener after first input
|
||||
htmlElement.removeEventListener('input', inputHandler)
|
||||
htmlElement.removeEventListener('change', inputHandler)
|
||||
clickHandler()
|
||||
}
|
||||
|
||||
htmlElement.addEventListener('input', inputHandler)
|
||||
htmlElement.addEventListener('change', inputHandler)
|
||||
|
||||
currentClickListener = {
|
||||
element: htmlElement,
|
||||
handler: inputHandler,
|
||||
originalTabIndex,
|
||||
eventTypes: ['input', 'change']
|
||||
}
|
||||
} else if (isSelectComponent) {
|
||||
// For Select components, listen for option selection clicks
|
||||
const selectOptionClickHandler = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
// Type safety: ensure target is an Element before using closest
|
||||
if (!(target instanceof Element)) {
|
||||
return
|
||||
}
|
||||
// Check if the clicked element is a select option
|
||||
if (target.closest('.select-option')) {
|
||||
// User selected an option, proceed to next step
|
||||
clickHandler()
|
||||
}
|
||||
}
|
||||
|
||||
const keyHandler = (e: KeyboardEvent) => {
|
||||
if (['Enter', ' '].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
// For select components, Enter/Space should open dropdown, not advance
|
||||
// Only advance if an option is focused
|
||||
const focusedOption = htmlElement.querySelector('.select-option:focus')
|
||||
if (focusedOption) {
|
||||
clickHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmlElement.addEventListener('click', selectOptionClickHandler)
|
||||
htmlElement.addEventListener('keydown', keyHandler)
|
||||
|
||||
currentClickListener = {
|
||||
element: htmlElement,
|
||||
handler: selectOptionClickHandler as () => void,
|
||||
keyHandler,
|
||||
originalTabIndex,
|
||||
eventTypes: ['click']
|
||||
}
|
||||
} else {
|
||||
const keyHandler = (e: KeyboardEvent) => {
|
||||
if (['Enter', ' '].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
clickHandler()
|
||||
}
|
||||
}
|
||||
|
||||
htmlElement.addEventListener('click', clickHandler, { once: true })
|
||||
htmlElement.addEventListener('keydown', keyHandler)
|
||||
|
||||
currentClickListener = {
|
||||
element: htmlElement,
|
||||
handler: clickHandler as () => void,
|
||||
keyHandler,
|
||||
originalTabIndex,
|
||||
eventTypes: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDestroyed: () => {
|
||||
if (currentClickListener) {
|
||||
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
|
||||
// Remove all tracked event types
|
||||
if (eventTypes) {
|
||||
eventTypes.forEach(type => el.removeEventListener(type, handler))
|
||||
}
|
||||
if (keyHandler) el.removeEventListener('keydown', keyHandler)
|
||||
if (originalTabIndex !== undefined) {
|
||||
if (originalTabIndex === null) el.removeAttribute('tabindex')
|
||||
else el.setAttribute('tabindex', originalTabIndex)
|
||||
}
|
||||
currentClickListener = null
|
||||
}
|
||||
// 清理全局监听器 (由此处唯一管理)
|
||||
if (globalKeyboardHandler) {
|
||||
document.removeEventListener('keydown', globalKeyboardHandler, { capture: true })
|
||||
globalKeyboardHandler = null
|
||||
}
|
||||
onboardingStore.setDriverInstance(null)
|
||||
}
|
||||
})
|
||||
|
||||
onboardingStore.setDriverInstance(driverInstance)
|
||||
|
||||
// 添加全局键盘监听器
|
||||
globalKeyboardHandler = (e: KeyboardEvent) => {
|
||||
if (!driverInstance?.isActive()) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (confirm(t('onboarding.confirmExit'))) {
|
||||
driverInstance.destroy()
|
||||
onboardingStore.setDriverInstance(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
const target = e.target as HTMLElement
|
||||
// 允许在输入框中使用方向键
|
||||
if (['INPUT', 'TEXTAREA'].includes(target?.tagName)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 对于交互式步骤,箭头键应该触发交互而非跳过
|
||||
const currentIndex = driverInstance!.getActiveIndex() ?? 0
|
||||
const currentStep = steps[currentIndex]
|
||||
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
|
||||
currentStep?.popover.showButtons[0] === 'close'
|
||||
|
||||
if (isInteractiveStep && currentStep.element) {
|
||||
const targetElement = typeof currentStep.element === 'string'
|
||||
? document.querySelector(currentStep.element) as HTMLElement
|
||||
: currentStep.element as HTMLElement
|
||||
|
||||
if (targetElement) {
|
||||
// 对于非输入类元素,提示用户需要点击或按Enter
|
||||
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
||||
if (isClickable) {
|
||||
// 不自动触发,只是停留提示
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非交互式步骤才允许箭头键翻页
|
||||
driverInstance!.moveNext()
|
||||
}
|
||||
else if (e.key === 'Enter') {
|
||||
const target = e.target as HTMLElement
|
||||
// 允许在输入框中使用回车
|
||||
if (['INPUT', 'TEXTAREA'].includes(target?.tagName)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 回车键处理交互式步骤
|
||||
const currentIndex = driverInstance!.getActiveIndex() ?? 0
|
||||
const currentStep = steps[currentIndex]
|
||||
const isInteractiveStep = currentStep?.popover?.showButtons?.length === 1 &&
|
||||
currentStep?.popover.showButtons[0] === 'close'
|
||||
|
||||
if (isInteractiveStep && currentStep.element) {
|
||||
const targetElement = typeof currentStep.element === 'string'
|
||||
? document.querySelector(currentStep.element) as HTMLElement
|
||||
: currentStep.element as HTMLElement
|
||||
|
||||
if (targetElement) {
|
||||
const isClickable = !['INPUT', 'TEXTAREA', 'SELECT'].includes(targetElement.tagName)
|
||||
if (isClickable) {
|
||||
targetElement.click()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
driverInstance!.moveNext()
|
||||
}
|
||||
else if (e.key === 'ArrowLeft') {
|
||||
const target = e.target as HTMLElement
|
||||
// 允许在输入框中使用方向键
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
driverInstance.movePrevious()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', globalKeyboardHandler, { capture: true })
|
||||
driverInstance.drive(startIndex)
|
||||
}
|
||||
|
||||
const nextStep = async (delay = 300) => {
|
||||
if (!driverInstance?.isActive()) return
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
driverInstance.moveNext()
|
||||
}
|
||||
|
||||
const isCurrentStep = (elementSelector: string): boolean => {
|
||||
if (!driverInstance?.isActive()) return false
|
||||
const activeElement = driverInstance.getActiveElement()
|
||||
return activeElement?.matches(elementSelector) ?? false
|
||||
}
|
||||
|
||||
const replayTour = () => {
|
||||
clearSeen()
|
||||
void startTour()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onboardingStore.setControlMethods({
|
||||
nextStep,
|
||||
isCurrentStep
|
||||
})
|
||||
|
||||
if (onboardingStore.isDriverActive()) {
|
||||
console.log('Tour already active, skipping auto-start')
|
||||
driverInstance = onboardingStore.getDriverInstance()
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.autoStart || hasSeen()) return
|
||||
autoStartTimer = setTimeout(() => {
|
||||
void startTour()
|
||||
}, TIMING.AUTO_START_DELAY_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 保持 driver 实例活跃,支持路由切换
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoStartTimer) {
|
||||
clearTimeout(autoStartTimer)
|
||||
autoStartTimer = null
|
||||
}
|
||||
// 关键修复:不再此处清理 globalKeyboardHandler,交由 driver.onDestroyed 管理
|
||||
onboardingStore.clearControlMethods()
|
||||
})
|
||||
|
||||
return {
|
||||
startTour,
|
||||
replayTour,
|
||||
nextStep,
|
||||
isCurrentStep,
|
||||
hasSeen,
|
||||
markAsSeen,
|
||||
clearSeen
|
||||
}
|
||||
}
|
||||
79
frontend/src/composables/useTourStepDescription.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const ADMIN_TOUR_STEP_KEYS = [
|
||||
'admin.welcome',
|
||||
'admin.groupManage',
|
||||
'admin.createGroup',
|
||||
'admin.groupName',
|
||||
'admin.groupPlatform',
|
||||
'admin.groupMultiplier',
|
||||
'admin.groupExclusive',
|
||||
'admin.groupSubmit',
|
||||
'admin.accountManage',
|
||||
'admin.createAccount',
|
||||
'admin.accountName',
|
||||
'admin.accountPlatform',
|
||||
'admin.accountType',
|
||||
'admin.accountPriority',
|
||||
'admin.accountGroups',
|
||||
'admin.accountSubmit',
|
||||
'admin.keyManage',
|
||||
'admin.createKey',
|
||||
'admin.keyName',
|
||||
'admin.keyGroup',
|
||||
'admin.keySubmit'
|
||||
] as const
|
||||
|
||||
export const USER_TOUR_STEP_KEYS = [
|
||||
'user.welcome',
|
||||
'user.keyManage',
|
||||
'user.createKey',
|
||||
'user.keyName',
|
||||
'user.keyGroup',
|
||||
'user.keySubmit'
|
||||
] as const
|
||||
|
||||
export const TOUR_STEP_KEYS = [...ADMIN_TOUR_STEP_KEYS, ...USER_TOUR_STEP_KEYS] as const
|
||||
|
||||
export type TourStepKey = (typeof TOUR_STEP_KEYS)[number]
|
||||
|
||||
export const TOUR_STEP_COMPONENTS: Record<TourStepKey, string> = {
|
||||
'admin.welcome': 'AdminWelcomeDescription',
|
||||
'admin.groupManage': 'AdminGroupManageDescription',
|
||||
'admin.createGroup': 'AdminCreateGroupDescription',
|
||||
'admin.groupName': 'AdminGroupNameDescription',
|
||||
'admin.groupPlatform': 'AdminGroupPlatformDescription',
|
||||
'admin.groupMultiplier': 'AdminGroupMultiplierDescription',
|
||||
'admin.groupExclusive': 'AdminGroupExclusiveDescription',
|
||||
'admin.groupSubmit': 'AdminGroupSubmitDescription',
|
||||
'admin.accountManage': 'AdminAccountManageDescription',
|
||||
'admin.createAccount': 'AdminCreateAccountDescription',
|
||||
'admin.accountName': 'AdminAccountNameDescription',
|
||||
'admin.accountPlatform': 'AdminAccountPlatformDescription',
|
||||
'admin.accountType': 'AdminAccountTypeDescription',
|
||||
'admin.accountPriority': 'AdminAccountPriorityDescription',
|
||||
'admin.accountGroups': 'AdminAccountGroupsDescription',
|
||||
'admin.accountSubmit': 'AdminAccountSubmitDescription',
|
||||
'admin.keyManage': 'AdminKeyManageDescription',
|
||||
'admin.createKey': 'AdminCreateKeyDescription',
|
||||
'admin.keyName': 'AdminKeyNameDescription',
|
||||
'admin.keyGroup': 'AdminKeyGroupDescription',
|
||||
'admin.keySubmit': 'AdminKeySubmitDescription',
|
||||
'user.welcome': 'UserWelcomeDescription',
|
||||
'user.keyManage': 'UserKeyManageDescription',
|
||||
'user.createKey': 'UserCreateKeyDescription',
|
||||
'user.keyName': 'UserKeyNameDescription',
|
||||
'user.keyGroup': 'UserKeyGroupDescription',
|
||||
'user.keySubmit': 'UserKeySubmitDescription'
|
||||
}
|
||||
|
||||
export const useTourStepDescription = () => {
|
||||
const getComponentName = (stepKey: TourStepKey) => TOUR_STEP_COMPONENTS[stepKey]
|
||||
|
||||
const isTourStepKey = (value: string): value is TourStepKey =>
|
||||
Object.prototype.hasOwnProperty.call(TOUR_STEP_COMPONENTS, value)
|
||||
|
||||
return {
|
||||
getComponentName,
|
||||
isTourStepKey,
|
||||
stepKeys: TOUR_STEP_KEYS
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ export const i18n = createI18n({
|
||||
messages: {
|
||||
en,
|
||||
zh
|
||||
}
|
||||
},
|
||||
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
|
||||
// 这些内容是内部定义的,不存在 XSS 风险
|
||||
warnHtmlMessage: false
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
|
||||
@@ -1489,5 +1489,150 @@ export default {
|
||||
resetIn: 'Resets in {time}',
|
||||
windowNotActive: 'Awaiting first use',
|
||||
usageOf: '{used} of {limit}'
|
||||
},
|
||||
|
||||
// Onboarding Tour
|
||||
onboarding: {
|
||||
restartTour: 'Restart Onboarding Tour',
|
||||
dontShowAgain: "Don't show again",
|
||||
dontShowAgainTitle: 'Permanently close onboarding guide',
|
||||
confirmDontShow: "Are you sure you don't want to see the onboarding guide again?\n\nYou can restart it anytime from the user menu in the top right corner.",
|
||||
confirmExit: 'Are you sure you want to exit the onboarding guide? You can restart it anytime from the top right menu.',
|
||||
interactiveHint: 'Press Enter or Click to continue',
|
||||
navigation: {
|
||||
flipPage: 'Flip Page',
|
||||
exit: 'Exit'
|
||||
},
|
||||
// Admin tour steps
|
||||
admin: {
|
||||
welcome: {
|
||||
title: '👋 Welcome to Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API is a powerful AI service gateway platform that helps you easily manage and distribute AI services.</p><p style="margin-bottom: 12px;"><b>🎯 Core Features:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>Group Management</b> - Create service tiers (VIP, Free Trial, etc.)</li><li>🔗 <b>Account Pool</b> - Connect multiple upstream AI service accounts</li><li>🔑 <b>Key Distribution</b> - Generate independent API Keys for users</li><li>💰 <b>Billing Control</b> - Flexible rate and quota management</li></ul><p style="color: #10b981; font-weight: 600;">Let\'s complete the initial setup in 3 minutes →</p></div>',
|
||||
nextBtn: 'Start Setup 🚀',
|
||||
prevBtn: 'Skip'
|
||||
},
|
||||
groupManage: {
|
||||
title: '📦 Step 1: Group Management',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>What is a Group?</b></p><p style="margin-bottom: 12px;">Groups are the core concept of Sub2API, like a "service package":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 Each group can contain multiple upstream accounts</li><li>💰 Each group has independent billing multiplier</li><li>👥 Can be set as public or exclusive</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Example:</b> You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Group Management" on the left sidebar</p></div>'
|
||||
},
|
||||
createGroup: {
|
||||
title: '➕ Create New Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Let\'s create your first group.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 Tip:</b> Recommend creating a test group first to familiarize yourself with the process</p><p style="color: #10b981; font-weight: 600;">👉 Click the "Create Group" button</p></div>'
|
||||
},
|
||||
groupName: {
|
||||
title: '✏️ 1. Group Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Give your group an easy-to-identify name.</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 Naming Suggestions:</b><ul style="margin: 8px 0 0 16px;"><li>"Test Group" - For testing</li><li>"VIP Premium" - High-quality service</li><li>"Free Trial" - Trial version</li></ul></div><p style="font-size: 13px; color: #6b7280;">Click "Next" when done</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupPlatform: {
|
||||
title: '🤖 2. Select Platform',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the AI platform this group supports.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 Platform Guide:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude models</li><li><b>OpenAI</b> - GPT models</li><li><b>Google</b> - Gemini models</li></ul></div><p style="font-size: 13px; color: #6b7280;">One group can only have one platform</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupMultiplier: {
|
||||
title: '💰 3. Rate Multiplier',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the billing multiplier to control user charges.</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ Billing Rules:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - Original price (cost price)</li><li><b>1.5</b> - User consumes $1, charged $1.5</li><li><b>2.0</b> - User consumes $1, charged $2</li><li><b>0.8</b> - Subsidy mode (loss-making)</li></ul></div><p style="font-size: 13px; color: #6b7280;">Recommend setting test group to 1.0</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupExclusive: {
|
||||
title: '🔒 4. Exclusive Group (Optional)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Control group visibility and access permissions.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 Permission Guide:</b><ul style="margin: 8px 0 0 16px;"><li><b>Off</b> - Public group, visible to all users</li><li><b>On</b> - Exclusive group, only for specified users</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Cases:</b> VIP exclusive, internal testing, special customers</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupSubmit: {
|
||||
title: '✅ Save Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Confirm the information and click create to save the group.</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Note:</b> Platform type cannot be changed after creation, but other settings can be edited anytime</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 Next Step:</b> After creation, we\'ll add upstream accounts to this group</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||
},
|
||||
accountManage: {
|
||||
title: '🔗 Step 2: Add Account',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Great! Group created successfully 🎉</b></p><p style="margin-bottom: 12px;">Now add upstream AI service accounts to enable actual service delivery.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 Account Purpose:</b><ul style="margin: 8px 0 0 16px;"><li>Connect to upstream AI services (Claude, GPT, etc.)</li><li>One group can contain multiple accounts (load balancing)</li><li>Supports OAuth and Session Key methods</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Account Management" on the left sidebar</p></div>'
|
||||
},
|
||||
createAccount: {
|
||||
title: '➕ Add New Account',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to start adding your first upstream account.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Recommend using OAuth method - more secure and no manual key extraction needed</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Add Account" button</p></div>'
|
||||
},
|
||||
accountName: {
|
||||
title: '✏️ 1. Account Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set an easy-to-identify name for the account.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Naming Suggestions:</b> "Claude Main", "GPT Backup 1", "Test Account", etc.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountPlatform: {
|
||||
title: '🤖 2. Select Platform',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the service provider platform for this account.</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ Important:</b> Platform must match the group you just created</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountType: {
|
||||
title: '🔐 3. Authorization Method',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the account authorization method.</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ Recommended: OAuth Method</b><ul style="margin: 8px 0 0 16px;"><li>No manual key extraction needed</li><li>More secure with auto-refresh support</li><li>Works with Claude Code, ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key Method</b><ul style="margin: 8px 0 0 16px;"><li>Requires manual extraction from browser</li><li>May need periodic updates</li><li>For platforms without OAuth support</li></ul></div></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. Priority (Optional)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Higher number = higher priority</li><li>System uses high-priority accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to high priority, backup accounts to low priority</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountGroups: {
|
||||
title: '🎯 5. Assign Groups',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Key Step!</b> Assign the account to the group you just created.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important Reminder:</b><ul style="margin: 8px 0 0 16px;"><li>Must select at least one group</li><li>Unassigned accounts cannot be used</li><li>One account can be assigned to multiple groups</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Select the test group you just created</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountSubmit: {
|
||||
title: '✅ Save Account',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Confirm the information and click save.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth Flow:</b><ul style="margin: 8px 0 0 16px;"><li>Will redirect to service provider page after clicking save</li><li>Complete login and authorization on provider page</li><li>Auto-return after successful authorization</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 Next Step:</b> After adding account, we\'ll create an API key</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Save" button</p></div>'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 Step 3: Generate Key',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Congratulations! Account setup complete 🎉</b></p><p style="margin-bottom: 12px;">Final step: generate an API Key to test if the service works properly.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key Purpose:</b><ul style="margin: 8px 0 0 16px;"><li>Credential for calling AI services</li><li>Each key is bound to one group</li><li>Can set quota and expiration</li><li>Supports independent usage statistics</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "API Keys" on the left sidebar</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ Create Key',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to create your first API Key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Copy and save immediately after creation - key is only shown once</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create Key" button</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 1. Key Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set an easy-to-manage name for the key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Naming Suggestions:</b> "Test Key", "Production", "Mobile", etc.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 2. Select Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Select the group you just configured.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 Group Determines:</b><ul style="margin: 8px 0 0 16px;"><li>Which accounts this key can use</li><li>What billing multiplier applies</li><li>Whether it\'s an exclusive key</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Select the test group you just created</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 Generate and Copy',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">System will generate a complete API Key after clicking create.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important Reminder:</b><ul style="margin: 8px 0 0 16px;"><li>Key is only shown once, copy immediately</li><li>Need to regenerate if lost</li><li>Keep it safe, don\'t share with others</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 Next Steps:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the generated sk-xxx key</li><li>Use in any OpenAI-compatible client</li><li>Start experiencing AI services!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||
}
|
||||
},
|
||||
// User tour steps
|
||||
user: {
|
||||
welcome: {
|
||||
title: '👋 Welcome to Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Hello! Welcome to the Sub2API AI service platform.</p><p style="margin-bottom: 12px;"><b>🎯 Quick Start:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 Create API Key</li><li>📋 Copy key to your application</li><li>🚀 Start using AI services</li></ul><p style="color: #10b981; font-weight: 600;">Just 1 minute, let\'s get started →</p></div>',
|
||||
nextBtn: 'Start 🚀',
|
||||
prevBtn: 'Skip'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 API Key Management',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Manage all your API access keys here.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 What is an API Key?</b><br/>An API key is your credential for accessing AI services, like a key that allows your application to call AI capabilities.</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click to enter key page</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ Create New Key',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to create your first API key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Key is only shown once after creation, make sure to copy and save</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create Key"</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ Key Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Give your key an easy-to-identify name.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Examples:</b> "My First Key", "For Testing", etc.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 Select Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Select the service group assigned by the administrator.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Group Info:</b><br/>Different groups may have different service quality and billing rates, choose according to your needs.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 Complete Creation',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click to confirm and create your API key.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the key (sk-xxx) immediately after creation</li><li>Key is only shown once, need to regenerate if lost</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 How to Use:</b><br/>Configure the key in any OpenAI-compatible client (like ChatBox, OpenCat, etc.) and start using!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,150 @@ export default {
|
||||
resetIn: '{time} 后重置',
|
||||
windowNotActive: '等待首次使用',
|
||||
usageOf: '已用 {used} / {limit}'
|
||||
},
|
||||
|
||||
// Onboarding Tour
|
||||
onboarding: {
|
||||
restartTour: '重新查看新手引导',
|
||||
dontShowAgain: '不再提示',
|
||||
dontShowAgainTitle: '永久关闭新手引导',
|
||||
confirmDontShow: '确定不再显示新手引导吗?\n\n您可以随时在右上角头像菜单中重新开启。',
|
||||
confirmExit: '确定要退出新手引导吗?您可以随时在右上角菜单重新开始。',
|
||||
interactiveHint: '按 Enter 或点击继续',
|
||||
navigation: {
|
||||
flipPage: '翻页',
|
||||
exit: '退出'
|
||||
},
|
||||
// Admin tour steps
|
||||
admin: {
|
||||
welcome: {
|
||||
title: '👋 欢迎使用 Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
|
||||
nextBtn: '开始配置 🚀',
|
||||
prevBtn: '跳过'
|
||||
},
|
||||
groupManage: {
|
||||
title: '📦 第一步:分组管理',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
|
||||
},
|
||||
createGroup: {
|
||||
title: '➕ 创建新分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
|
||||
},
|
||||
groupName: {
|
||||
title: '✏️ 1. 分组名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupPlatform: {
|
||||
title: '🤖 2. 选择平台',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupMultiplier: {
|
||||
title: '💰 3. 费率倍数',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupExclusive: {
|
||||
title: '🔒 4. 专属分组(可选)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupSubmit: {
|
||||
title: '✅ 保存分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
},
|
||||
accountManage: {
|
||||
title: '🔗 第二步:添加账号',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
|
||||
},
|
||||
createAccount: {
|
||||
title: '➕ 添加新账号',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
|
||||
},
|
||||
accountName: {
|
||||
title: '✏️ 1. 账号名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountPlatform: {
|
||||
title: '🤖 2. 选择平台',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountType: {
|
||||
title: '🔐 3. 授权方式',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. 优先级(可选)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越大,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountGroups: {
|
||||
title: '🎯 5. 分配分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountSubmit: {
|
||||
title: '✅ 保存账号',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 第三步:生成密钥',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ 创建密钥',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 1. 密钥名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 2. 选择分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 生成并复制',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
},
|
||||
// User tour steps
|
||||
user: {
|
||||
welcome: {
|
||||
title: '👋 欢迎使用 Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
|
||||
nextBtn: '开始 🚀',
|
||||
prevBtn: '跳过'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 API 密钥管理',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ 创建新密钥',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 密钥名称',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 选择分组',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 完成创建',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export { useAuthStore } from './auth'
|
||||
export { useAppStore } from './app'
|
||||
export { useSubscriptionStore } from './subscriptions'
|
||||
export { useOnboardingStore } from './onboarding'
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
88
frontend/src/stores/onboarding.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Onboarding Store
|
||||
* Manages onboarding tour state and control methods
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { markRaw, ref, shallowRef } from 'vue'
|
||||
import type { Driver } from 'driver.js'
|
||||
|
||||
type VoidCallback = () => void
|
||||
type NextStepCallback = (delay?: number) => Promise<void>
|
||||
type IsCurrentStepCallback = (selector: string) => boolean
|
||||
|
||||
export const useOnboardingStore = defineStore('onboarding', () => {
|
||||
const replayCallback = ref<VoidCallback | null>(null)
|
||||
const nextStepCallback = ref<NextStepCallback | null>(null)
|
||||
const isCurrentStepCallback = ref<IsCurrentStepCallback | null>(null)
|
||||
|
||||
// 全局 driver 实例,跨组件保持
|
||||
const driverInstance = shallowRef<Driver | null>(null)
|
||||
|
||||
function setReplayCallback(callback: VoidCallback | null): void {
|
||||
replayCallback.value = callback
|
||||
}
|
||||
|
||||
function setControlMethods(methods: {
|
||||
nextStep: NextStepCallback,
|
||||
isCurrentStep: IsCurrentStepCallback
|
||||
}): void {
|
||||
nextStepCallback.value = methods.nextStep
|
||||
isCurrentStepCallback.value = methods.isCurrentStep
|
||||
}
|
||||
|
||||
function clearControlMethods(): void {
|
||||
nextStepCallback.value = null
|
||||
isCurrentStepCallback.value = null
|
||||
}
|
||||
|
||||
function setDriverInstance(driver: Driver | null): void {
|
||||
driverInstance.value = driver ? markRaw(driver) : null
|
||||
}
|
||||
|
||||
function getDriverInstance(): Driver | null {
|
||||
return driverInstance.value
|
||||
}
|
||||
|
||||
function isDriverActive(): boolean {
|
||||
return driverInstance.value?.isActive?.() ?? false
|
||||
}
|
||||
|
||||
function replay(): void {
|
||||
if (replayCallback.value) {
|
||||
replayCallback.value()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually advance to the next step
|
||||
* @param delay Optional delay in ms (useful for waiting for animations)
|
||||
*/
|
||||
async function nextStep(delay = 0): Promise<void> {
|
||||
if (nextStepCallback.value) {
|
||||
await nextStepCallback.value(delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tour is currently highlighting a specific element
|
||||
*/
|
||||
function isCurrentStep(selector: string): boolean {
|
||||
if (isCurrentStepCallback.value) {
|
||||
return isCurrentStepCallback.value(selector)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
setReplayCallback,
|
||||
setControlMethods,
|
||||
clearControlMethods,
|
||||
setDriverInstance,
|
||||
getDriverInstance,
|
||||
isDriverActive,
|
||||
replay,
|
||||
nextStep,
|
||||
isCurrentStep
|
||||
}
|
||||
})
|
||||
@@ -518,6 +518,43 @@
|
||||
@apply overflow-x-auto rounded-xl p-4;
|
||||
}
|
||||
|
||||
/* ============ Tour Description ============ */
|
||||
.tour-step-description {
|
||||
@apply space-y-3 text-sm leading-relaxed text-gray-700 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.tour-step-description ul {
|
||||
@apply list-disc pl-5;
|
||||
}
|
||||
|
||||
.tour-step-description ol {
|
||||
@apply list-decimal pl-5;
|
||||
}
|
||||
|
||||
.tour-step-description li + li {
|
||||
@apply mt-1;
|
||||
}
|
||||
|
||||
.tour-info-box {
|
||||
@apply rounded-md border-l-4 border-blue-500 bg-blue-50 px-3 py-2 text-xs text-blue-900;
|
||||
@apply dark:border-blue-400 dark:bg-blue-950/40 dark:text-blue-200;
|
||||
}
|
||||
|
||||
.tour-success-box {
|
||||
@apply rounded-md border-l-4 border-emerald-500 bg-emerald-50 px-3 py-2 text-xs text-emerald-900;
|
||||
@apply dark:border-emerald-400 dark:bg-emerald-950/40 dark:text-emerald-200;
|
||||
}
|
||||
|
||||
.tour-warning-box {
|
||||
@apply rounded-md border-l-4 border-amber-500 bg-amber-50 px-3 py-2 text-xs text-amber-900;
|
||||
@apply dark:border-amber-400 dark:bg-amber-950/40 dark:text-amber-200;
|
||||
}
|
||||
|
||||
.tour-error-box {
|
||||
@apply rounded-md border-l-4 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-900;
|
||||
@apply dark:border-red-400 dark:bg-red-950/40 dark:text-red-200;
|
||||
}
|
||||
|
||||
/* ============ 表格页面布局优化 ============ */
|
||||
/* 表格容器 - 默认仅支持水平滚动 */
|
||||
.table-wrapper {
|
||||
|
||||
247
frontend/src/styles/onboarding.css
Normal file
@@ -0,0 +1,247 @@
|
||||
/* Sub2API Interactive Tour Styles - DOM Restructured Version */
|
||||
|
||||
/* 1. Overlay & Highlight */
|
||||
.driver-overlay {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
z-index: 99999998 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.driver-active-element {
|
||||
position: relative !important;
|
||||
z-index: 99999999 !important;
|
||||
outline: 4px solid rgba(20, 184, 166, 0.2) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
/* 2. Popover Container */
|
||||
.driver-popover.theme-tour-popover {
|
||||
position: fixed !important;
|
||||
z-index: 100000000 !important;
|
||||
background-color: #ffffff !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 0 !important;
|
||||
max-width: min(440px, 90vw) !important; /* Responsive on small screens */
|
||||
color: #1f2937 !important;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.dark .driver-popover.theme-tour-popover {
|
||||
background-color: #1e293b !important;
|
||||
border-color: #334155 !important;
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
/* 3. Header Area */
|
||||
.theme-tour-popover .driver-popover-title {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 20px 24px 12px 24px !important;
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.driver-popover-title-text {
|
||||
font-size: 18px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #111827 !important;
|
||||
line-height: 1.3 !important;
|
||||
padding-right: 100px !important; /* Ensure title doesn't overlap Skip/Close */
|
||||
}
|
||||
.dark .driver-popover-title-text { color: #ffffff !important; }
|
||||
|
||||
/* Skip Button */
|
||||
.header-skip-btn {
|
||||
position: absolute !important;
|
||||
top: 18px !important;
|
||||
right: 60px !important;
|
||||
font-size: 12px !important;
|
||||
color: #9ca3af !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 4px 8px !important;
|
||||
cursor: pointer !important;
|
||||
border-radius: 4px !important;
|
||||
transition: all 0.2s !important;
|
||||
white-space: nowrap !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 28px !important;
|
||||
max-width: 120px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
.header-skip-btn:hover {
|
||||
background-color: rgba(239, 68, 68, 0.1) !important;
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
.dark .header-skip-btn:hover {
|
||||
background-color: rgba(248, 113, 113, 0.1) !important;
|
||||
color: #f87171 !important;
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.theme-tour-popover .driver-popover-close-btn {
|
||||
position: absolute !important;
|
||||
top: 18px !important;
|
||||
right: 20px !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
color: #9ca3af !important;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
z-index: 20 !important;
|
||||
border-radius: 4px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.theme-tour-popover .driver-popover-close-btn:hover { background-color: #f3f4f6 !important; color: #4b5563 !important; }
|
||||
.dark .theme-tour-popover .driver-popover-close-btn:hover { background-color: #334155 !important; }
|
||||
|
||||
/* 4. Body Content */
|
||||
.theme-tour-popover .driver-popover-description {
|
||||
display: block !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 400 !important;
|
||||
color: #4b5563 !important;
|
||||
padding: 0 24px 24px 24px !important;
|
||||
margin: 0 !important;
|
||||
line-height: 1.6 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.dark .theme-tour-popover .driver-popover-description { color: #cbd5e1 !important; }
|
||||
|
||||
/* 5. Footer Area - Flex Row with Left/Right Containers */
|
||||
.theme-tour-popover .driver-popover-footer {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important; /* Push Left and Right apart */
|
||||
padding: 16px 24px !important;
|
||||
background-color: #f9fafb !important;
|
||||
border-top: 1px solid #f3f4f6 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.dark .theme-tour-popover .driver-popover-footer {
|
||||
background-color: #0f172a !important;
|
||||
border-top-color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Left Container: Progress + Shortcuts */
|
||||
.footer-left {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 16px !important;
|
||||
}
|
||||
|
||||
/* Right Container: Buttons */
|
||||
.footer-right {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.theme-tour-popover .driver-popover-progress-text {
|
||||
font-size: 13px !important;
|
||||
color: #6b7280 !important;
|
||||
margin: 0 !important;
|
||||
font-weight: 500 !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.dark .theme-tour-popover .driver-popover-progress-text { color: #9ca3af !important; }
|
||||
|
||||
/* Shortcuts (Divider + Keys) */
|
||||
.footer-shortcuts {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
padding-left: 16px !important;
|
||||
border-left: 1px solid #e5e7eb !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
.dark .footer-shortcuts { border-left-color: #334155 !important; }
|
||||
|
||||
.shortcut-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
font-size: 12px !important;
|
||||
color: #6b7280 !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.dark .shortcut-item { color: #94a3b8 !important; }
|
||||
|
||||
.shortcut-item kbd {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace !important;
|
||||
background-color: #ffffff !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 1px 6px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #4b5563 !important;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.05) !important;
|
||||
min-width: 20px !important;
|
||||
text-align: center !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
.dark .shortcut-item kbd {
|
||||
background-color: #1e293b !important;
|
||||
border-color: #475569 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
/* Nav Buttons */
|
||||
.theme-tour-popover button {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 8px 16px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s !important;
|
||||
border: 1px solid transparent !important;
|
||||
line-height: 1.2 !important;
|
||||
white-space: nowrap !important; /* Force no wrap */
|
||||
}
|
||||
|
||||
.theme-tour-popover .driver-popover-next-btn {
|
||||
background-color: #14b8a6 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
.theme-tour-popover .driver-popover-next-btn:hover { background-color: #0d9488 !important; }
|
||||
|
||||
.theme-tour-popover .driver-popover-prev-btn {
|
||||
background-color: white !important;
|
||||
color: #6b7280 !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
.theme-tour-popover .driver-popover-prev-btn:hover { background-color: #f9fafb !important; color: #374151 !important; }
|
||||
.dark .theme-tour-popover .driver-popover-prev-btn {
|
||||
background-color: #1e293b !important;
|
||||
border-color: #475569 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.driver-popover-arrow { z-index: 100000001 !important; }
|
||||
.driver-popover-arrow-side-left.driver-popover-arrow { border-left-color: #ffffff !important; }
|
||||
.driver-popover-arrow-side-right.driver-popover-arrow { border-right-color: #ffffff !important; }
|
||||
.driver-popover-arrow-side-top.driver-popover-arrow { border-top-color: #ffffff !important; }
|
||||
.driver-popover-arrow-side-bottom.driver-popover-arrow { border-bottom-color: #ffffff !important; }
|
||||
|
||||
.dark .driver-popover-arrow-side-left.driver-popover-arrow { border-left-color: #1e293b !important; }
|
||||
.dark .driver-popover-arrow-side-right.driver-popover-arrow { border-right-color: #1e293b !important; }
|
||||
.dark .driver-popover-arrow-side-top.driver-popover-arrow { border-top-color: #1e293b !important; }
|
||||
.dark .driver-popover-arrow-side-bottom.driver-popover-arrow { border-bottom-color: #1e293b !important; }
|
||||
@@ -38,7 +38,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="accounts-create-btn">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -373,7 +373,7 @@
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="showCreateModal = false"
|
||||
@created="loadAccounts"
|
||||
@created="() => { loadAccounts(); if (onboardingStore.isCurrentStep(`[data-tour='account-form-submit']`)) onboardingStore.nextStep(500) }"
|
||||
/>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
@@ -495,6 +495,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicIn
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
@@ -524,6 +525,7 @@ import { formatRelativeTime } from '@/utils/format'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
// Table columns
|
||||
const columns = computed<Column[]>(() => {
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
data-tour="groups-create-btn"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -244,6 +248,7 @@
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.enterGroupName')"
|
||||
data-tour="group-form-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -257,7 +262,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select v-model="createForm.platform" :options="platformOptions" />
|
||||
<Select
|
||||
v-model="createForm.platform"
|
||||
:options="platformOptions"
|
||||
data-tour="group-form-platform"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
@@ -269,10 +278,11 @@
|
||||
min="0.001"
|
||||
required
|
||||
class="input"
|
||||
data-tour="group-form-multiplier"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
<div v-if="createForm.subscription_type !== 'subscription'" data-tour="group-form-exclusive">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.form.exclusive') }}
|
||||
@@ -390,6 +400,7 @@
|
||||
form="create-group-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="group-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
@@ -432,7 +443,13 @@
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input v-model="editForm.name" type="text" required class="input" />
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
data-tour="edit-group-form-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||
@@ -440,7 +457,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select v-model="editForm.platform" :options="platformOptions" :disabled="true" />
|
||||
<Select
|
||||
v-model="editForm.platform"
|
||||
:options="platformOptions"
|
||||
:disabled="true"
|
||||
data-tour="group-form-platform"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
@@ -452,6 +474,7 @@
|
||||
min="0.001"
|
||||
required
|
||||
class="input"
|
||||
data-tour="group-form-multiplier"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
@@ -580,6 +603,7 @@
|
||||
form="edit-group-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="group-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
@@ -625,6 +649,7 @@
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
@@ -640,6 +665,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
||||
@@ -809,9 +835,14 @@ const handleCreateGroup = async () => {
|
||||
appStore.showSuccess(t('admin.groups.groupCreated'))
|
||||
closeCreateModal()
|
||||
loadGroups()
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="group-form-submit"]')) {
|
||||
onboardingStore.nextStep(500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
|
||||
console.error('Error creating group:', error)
|
||||
// Don't advance tour on error
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="keys-create-btn">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
@@ -313,6 +313,7 @@
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('keys.namePlaceholder')"
|
||||
data-tour="key-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -322,6 +323,7 @@
|
||||
v-model="formData.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('keys.selectGroup')"
|
||||
data-tour="key-form-group"
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
@@ -391,7 +393,13 @@
|
||||
<button @click="closeModals" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
form="key-form"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
data-tour="key-form-submit"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -496,6 +504,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -524,6 +533,7 @@ interface GroupOption {
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
@@ -812,12 +822,17 @@ const handleSubmit = async () => {
|
||||
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
|
||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
||||
onboardingStore.nextStep(500)
|
||||
}
|
||||
}
|
||||
closeModals()
|
||||
loadApiKeys()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
||||
appStore.showError(errorMsg)
|
||||
// Don't advance tour on error
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
BIN
tour-refactoring-success.png
Normal file
|
After Width: | Height: | Size: 673 KiB |