Merge branch 'main' into test-dev

This commit is contained in:
yangjianbo
2025-12-30 08:41:49 +08:00
30 changed files with 4816 additions and 1676 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,17 @@
"@vueuse/core": "^10.7.0",
"axios": "^1.6.2",
"chart.js": "^4.4.1",
"driver.js": "^1.4.0",
"file-saver": "^2.0.5",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.0",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.16",

1962
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,309 @@
import { DriveStep } from 'driver.js'
/**
* 管理员完整引导流程
* 交互式引导:指引用户实际操作
* @param t 国际化函数
* @param isSimpleMode 是否为简易模式(简易模式下会过滤分组相关步骤)
*/
export const getAdminSteps = (t: (key: string) => string, isSimpleMode = false): DriveStep[] => {
const allSteps: 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: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-platform"]',
popover: {
title: t('onboarding.admin.groupPlatform.title'),
description: t('onboarding.admin.groupPlatform.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-multiplier"]',
popover: {
title: t('onboarding.admin.groupMultiplier.title'),
description: t('onboarding.admin.groupMultiplier.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="group-form-exclusive"]',
popover: {
title: t('onboarding.admin.groupExclusive.title'),
description: t('onboarding.admin.groupExclusive.description'),
side: 'top',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
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: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-platform"]',
popover: {
title: t('onboarding.admin.accountPlatform.title'),
description: t('onboarding.admin.accountPlatform.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-type"]',
popover: {
title: t('onboarding.admin.accountType.title'),
description: t('onboarding.admin.accountType.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-priority"]',
popover: {
title: t('onboarding.admin.accountPriority.title'),
description: t('onboarding.admin.accountPriority.description'),
side: 'top',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
element: '[data-tour="account-form-groups"]',
popover: {
title: t('onboarding.admin.accountGroups.title'),
description: t('onboarding.admin.accountGroups.description'),
side: 'top',
align: 'center',
showButtons: ['next', 'previous']
}
},
{
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: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-group"]',
popover: {
title: t('onboarding.admin.keyGroup.title'),
description: t('onboarding.admin.keyGroup.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
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']
}
}
]
// 简易模式下过滤分组相关步骤
if (isSimpleMode) {
return allSteps.filter(step => {
const element = step.element as string | undefined
// 过滤掉分组管理和账号分组选择相关步骤
return !element || (
!element.includes('sidebar-group-manage') &&
!element.includes('groups-create-btn') &&
!element.includes('group-form-') &&
!element.includes('account-form-groups')
)
})
}
return allSteps
}
/**
* 普通用户引导流程
*/
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: ['next', 'previous']
}
},
{
element: '[data-tour="key-form-group"]',
popover: {
title: t('onboarding.user.keyGroup.title'),
description: t('onboarding.user.keyGroup.description'),
side: 'right',
align: 'start',
showButtons: ['next', 'previous']
}
},
{
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']
}
}
]

View File

@@ -362,6 +362,10 @@ const resetState = () => {
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close')
}

View File

@@ -2,7 +2,7 @@
<BaseDialog
:show="show"
:title="t('admin.accounts.createAccount')"
width="wide"
width="normal"
@close="handleClose"
>
<!-- Step Indicator for OAuth accounts -->
@@ -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"

View File

@@ -2,7 +2,7 @@
<BaseDialog
:show="show"
:title="t('admin.accounts.editAccount')"
width="wide"
width="normal"
@close="handleClose"
>
<form
@@ -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"

View File

@@ -2,7 +2,7 @@
<BaseDialog
:show="show"
:title="t('admin.accounts.reAuthorizeAccount')"
width="wide"
width="normal"
@close="handleClose"
>
<div v-if="account" class="space-y-4">

View File

@@ -151,6 +151,10 @@ watch(
)
const handleClose = () => {
// 防止在同步进行中关闭对话框
if (syncing.value) {
return
}
emit('close')
}

View File

@@ -1,53 +1,63 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-overlay"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div :class="['modal-content', widthClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3 id="modal-title" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
<Transition name="modal">
<div
v-if="show"
class="modal-overlay"
:aria-labelledby="dialogId"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div ref="dialogRef" :class="['modal-content', widthClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3 :id="dialogId" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
import { computed, watch, onMounted, onUnmounted, ref, nextTick } from 'vue'
// 生成唯一ID以避免多个对话框时ID冲突
let dialogIdCounter = 0
const dialogId = `modal-title-${++dialogIdCounter}`
// 焦点管理
const dialogRef = ref<HTMLElement | null>(null)
let previousActiveElement: HTMLElement | null = null
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const widthClasses = computed(() => {
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
// full=full-screen or very dense layouts.
const widths: Record<DialogWidth, string> = {
narrow: 'max-w-md',
normal: 'max-w-lg',
wide: 'max-w-4xl',
'extra-wide': 'max-w-6xl',
full: 'max-w-7xl'
wide: 'w-full sm:max-w-2xl md:max-w-3xl lg:max-w-4xl',
'extra-wide': 'w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl',
full: 'w-full sm:max-w-4xl md:max-w-5xl lg:max-w-6xl xl:max-w-7xl'
}
return widths[props.width]
})
@@ -94,14 +107,31 @@ const handleEscape = (event: KeyboardEvent) => {
}
}
// Prevent body scroll when modal is open
// Prevent body scroll when modal is open and manage focus
watch(
() => props.show,
(isOpen) => {
async (isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
// 保存当前焦点元素
previousActiveElement = document.activeElement as HTMLElement
// 使用CSS类而不是直接操作style,更易于管理多个对话框
document.body.classList.add('modal-open')
// 等待DOM更新后设置焦点到对话框
await nextTick()
if (dialogRef.value) {
const firstFocusable = dialogRef.value.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
firstFocusable?.focus()
}
} else {
document.body.style.overflow = ''
document.body.classList.remove('modal-open')
// 恢复之前的焦点
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
previousActiveElement.focus()
}
previousActiveElement = null
}
},
{ immediate: true }
@@ -113,6 +143,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
// 确保组件卸载时移除滚动锁定
document.body.classList.remove('modal-open')
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<BaseDialog :show="show" :title="t('usage.exporting')" width="narrow" @close="handleCancel">
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ t('usage.exportingProgress') }}
</div>
<div class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
<span>{{ t('usage.exportedCount', { current, total }) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ normalizedProgress }}%</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-dark-700">
<div
role="progressbar"
:aria-valuenow="normalizedProgress"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="`${t('usage.exportingProgress')}: ${normalizedProgress}%`"
class="h-2 rounded-full bg-primary-600 transition-all"
:style="{ width: `${normalizedProgress}%` }"
></div>
</div>
<div v-if="estimatedTime" class="text-xs text-gray-500 dark:text-gray-400" aria-live="polite" aria-atomic="true">
{{ t('usage.estimatedTime', { time: estimatedTime }) }}
</div>
</div>
<template #footer>
<button
@click="handleCancel"
type="button"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
>
{{ t('usage.cancelExport') }}
</button>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from './BaseDialog.vue'
interface Props {
show: boolean
progress: number
current: number
total: number
estimatedTime: string
}
interface Emits {
(e: 'cancel'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const normalizedProgress = computed(() => {
const value = Number.isFinite(props.progress) ? props.progress : 0
return Math.min(100, Math.max(0, Math.round(value)))
})
const handleCancel = () => {
emit('cancel')
}
</script>

View File

@@ -1,120 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-overlay"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div :class="['modal-content', sizeClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3 id="modal-title" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
interface Props {
show: boolean
title: string
size?: ModalSize
closeOnEscape?: boolean
closeOnClickOutside?: boolean
}
interface Emits {
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closeOnEscape: true,
closeOnClickOutside: false
})
const emit = defineEmits<Emits>()
const sizeClasses = computed(() => {
const sizes: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-5xl',
full: 'max-w-4xl'
}
return sizes[props.size]
})
const handleClose = () => {
if (props.closeOnClickOutside) {
emit('close')
}
}
const handleEscape = (event: KeyboardEvent) => {
if (props.show && props.closeOnEscape && event.key === 'Escape') {
emit('close')
}
}
// Prevent body scroll when modal is open
watch(
() => props.show,
(isOpen) => {
console.log('[Modal] show changed to:', isOpen)
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
</script>

View File

@@ -29,67 +29,73 @@
</span>
</button>
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
>
<!-- Search input -->
<div v-if="searchable" class="select-search">
<svg
class="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<Teleport to="body">
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
class="select-dropdown-portal"
:style="dropdownStyle"
@click.stop
@mousedown.stop
>
<!-- Search input -->
<div v-if="searchable" class="select-search">
<svg
class="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholderText"
class="select-search-input"
@click.stop
/>
</svg>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholderText"
class="select-search-input"
@click.stop
/>
</div>
<!-- Options list -->
<div class="select-options">
<div
v-for="option in filteredOptions"
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']"
>
<slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
<svg
v-if="isSelected(option)"
class="h-4 w-4 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</slot>
</div>
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
<!-- Options list -->
<div class="select-options">
<div
v-for="option in filteredOptions"
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']"
>
<slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
<svg
v-if="isSelected(option)"
class="h-4 w-4 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</slot>
</div>
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
</div>
</div>
</div>
</div>
</Transition>
</Transition>
</Teleport>
</div>
</template>
@@ -147,6 +153,28 @@ const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const triggerRect = ref<DOMRect | null>(null)
// Computed style for teleported dropdown
const dropdownStyle = computed(() => {
if (!triggerRect.value) return {}
const rect = triggerRect.value
const style: Record<string, string> = {
position: 'fixed',
left: `${rect.left}px`,
minWidth: `${rect.width}px`,
zIndex: '100000020' // Higher than driver.js overlay (99999998)
}
if (dropdownPosition.value === 'top') {
style.bottom = `${window.innerHeight - rect.top + 8}px`
} else {
style.top = `${rect.bottom + 8}px`
}
return style
})
const getOptionValue = (
option: SelectOption | Record<string, unknown>
@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
const calculateDropdownPosition = () => {
if (!containerRef.value) return
// Update trigger rect for positioning
triggerRect.value = containerRef.value.getBoundingClientRect()
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const rect = triggerRect.value!
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = rect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => {
}
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false
searchQuery.value = ''
const target = event.target as HTMLElement
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref
if (target.closest('.select-dropdown-portal')) {
return // 点击在下拉菜单内,不关闭
}
// 检查是否点击在触发器内
if (containerRef.value && containerRef.value.contains(target)) {
return // 点击在触发器内,让 toggle 处理
}
// 点击在外部,关闭下拉菜单
isOpen.value = false
searchQuery.value = ''
}
const handleEscape = (event: KeyboardEvent) => {
@@ -295,54 +337,57 @@ onUnmounted(() => {
.select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
}
</style>
.select-dropdown {
@apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px];
<!-- Global styles for teleported dropdown -->
<style>
.select-dropdown-portal {
@apply w-max max-w-[300px];
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events: auto !important;
}
.select-dropdown-top {
@apply bottom-full mb-2 mt-0;
}
.select-search {
.select-dropdown-portal .select-search {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
}
.select-search-input {
.select-dropdown-portal .select-search-input {
@apply flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none;
}
.select-options {
.select-dropdown-portal .select-options {
@apply max-h-60 overflow-y-auto py-1;
}
.select-option {
.select-dropdown-portal .select-option {
@apply flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
/* 确保选项在引导期间可点击 */
pointer-events: auto !important;
}
.select-option-selected {
.select-dropdown-portal .select-option-selected {
@apply bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300;
}
.select-option-label {
.select-dropdown-portal .select-option-label {
@apply flex-1 min-w-0 truncate text-left;
}
.select-empty {
.select-dropdown-portal .select-empty {
@apply px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400;
}
@@ -356,17 +401,6 @@ onUnmounted(() => {
.select-dropdown-enter-from,
.select-dropdown-leave-to {
opacity: 0;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown:not(.select-dropdown-top).select-dropdown-enter-from,
.select-dropdown:not(.select-dropdown-top).select-dropdown-leave-to {
transform: translateY(-8px);
}
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from,
.select-dropdown-top.select-dropdown-leave-to {
transform: translateY(8px);
}
</style>

View File

@@ -1,7 +1,6 @@
// Export all common components
export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue'
export { default as BaseDialog } from './BaseDialog.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue'
@@ -9,6 +8,7 @@ export { default as Toast } from './Toast.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
export { default as ExportProgressDialog } from './ExportProgressDialog.vue'
// Export types
export type { Column } from './types'

View File

@@ -199,6 +199,17 @@
</div>
</div>
<div v-if="showOnboardingButton" 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>
</div>
<div class="border-t border-gray-100 py-1 dark:border-dark-700">
<button
@click="handleLogout"
@@ -232,7 +243,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,12 +252,18 @@ const route = useRoute()
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const user = computed(() => authStore.user)
const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo)
// 只在标准模式的管理员下显示新手引导按钮
const showOnboardingButton = computed(() => {
return !authStore.isSimpleMode && user.value?.role === 'admin'
})
const userInitials = computed(() => {
if (!user.value) return ''
// Prefer username, fallback to email
@@ -300,6 +317,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()

View File

@@ -23,11 +23,30 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import '@/styles/onboarding.css'
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import { useOnboardingTour } from '@/composables/useOnboardingTour'
import { useOnboardingStore } from '@/stores/onboarding'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
const appStore = useAppStore()
const authStore = useAuthStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const isAdmin = computed(() => authStore.user?.role === 'admin')
const { replayTour } = useOnboardingTour({
storageKey: isAdmin.value ? 'admin_guide' : 'user_guide',
autoStart: true
})
const onboardingStore = useOnboardingStore()
onMounted(() => {
onboardingStore.setReplayCallback(replayTour)
})
defineExpose({ replayTour })
</script>

View File

@@ -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 {

View File

@@ -0,0 +1,569 @@
import { 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 {
storageKey?: string
autoStart?: boolean
}
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
ELEMENT_TIMEOUT_MS: 8000, // Timeout for element detection
AUTO_START_DELAY_MS: 1000 // Delay before auto-starting tour
} as const
// Helper: Check if a step is interactive (only close button shown)
const isInteractiveStep = (step: DriveStep): boolean => {
return step.popover?.showButtons?.length === 1 &&
step.popover.showButtons[0] === 'close'
}
// Helper: Clean up click listener
const cleanupClickListener = () => {
if (!currentClickListener) return
const { element: el, handler, keyHandler, originalTabIndex, eventTypes } = currentClickListener
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
}
// 使用 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 isSimpleMode = userStore.isSimpleMode
const steps = isAdmin ? getAdminSteps(t, isSimpleMode) : 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]
if (currentStep && isInteractiveStep(currentStep) && 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: () => {
markAsSeen()
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_DONE_BTN = 'driver-popover-done-btn'
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.5 交互式步骤提示
const currentStep = steps[state.activeIndex ?? 0]
if (currentStep && isInteractiveStep(currentStep) && 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) => {
// 清理之前的监听器
cleanupClickListener()
// 尝试等待元素
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
}
if (isInteractiveStep(step) && 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) {
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')
// Select dropdowns are teleported to <body>, so click events on options
// won't bubble through this element. Skip auto-advance for Select components.
// Users navigate using Next/Previous buttons after making their selection.
if (isSelectComponent) {
return
}
// Single-execution protection flag
let hasExecuted = false
// Capture the step index when binding the handler
const boundStepIndex = driverInstance?.getActiveIndex() ?? 0
const clickHandler = async () => {
// Prevent duplicate execution
if (hasExecuted) {
return
}
hasExecuted = true
// Wait before advancing to allow user to see the result of their action
await new Promise(resolve => setTimeout(resolve, TIMING.INTERACTIVE_WAIT_MS))
// Verify driver is still active and not destroyed
if (!driverInstance || !driverInstance.isActive()) {
return
}
// Check if we're still on the same step - abort if step changed during wait
const currentIndex = driverInstance.getActiveIndex() ?? 0
if (currentIndex !== boundStepIndex) {
return
}
const nextStep = steps[currentIndex + 1]
if (nextStep?.element && typeof nextStep.element === 'string') {
const exists = await ensureElement(nextStep.element, TIMING.ELEMENT_TIMEOUT_MS)
if (!exists) {
console.warn(`Onboarding: Next step element not found: ${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 {
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: () => {
cleanupClickListener()
// 清理全局监听器 (由此处唯一管理)
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()
markAsSeen()
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]
if (currentStep && isInteractiveStep(currentStep) && 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]
if (currentStep && isInteractiveStep(currentStep) && 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()) {
driverInstance = onboardingStore.getDriverInstance()
return
}
// 简易模式下禁用新手引导
if (userStore.isSimpleMode) {
return
}
// 只在管理员+标准模式下自动启动
const isAdmin = userStore.user?.role === 'admin'
if (!isAdmin) {
return
}
if (!options.autoStart || hasSeen()) return
autoStartTimer = setTimeout(() => {
void startTour()
}, TIMING.AUTO_START_DELAY_MS)
})
onUnmounted(() => {
if (autoStartTimer) {
clearTimeout(autoStartTimer)
autoStartTimer = null
}
// 关键修复:不再此处清理 globalKeyboardHandler交由 driver.onDestroyed 管理
onboardingStore.clearControlMethods()
})
return {
startTour,
replayTour,
nextStep,
isCurrentStep,
hasSeen,
markAsSeen,
clearSeen
}
}

View File

@@ -27,7 +27,10 @@ export const i18n = createI18n({
messages: {
en,
zh
}
},
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容driver.js 支持 HTML
// 这些内容是内部定义的,不存在 XSS 风险
warnHtmlMessage: false
})
export function setLocale(locale: string) {

View File

@@ -326,7 +326,8 @@ export default {
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
customKeyTooShort: 'Custom key must be at least 16 characters',
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
customKeyRequired: 'Please enter a custom key'
customKeyRequired: 'Please enter a custom key',
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.'
},
// Usage
@@ -345,6 +346,12 @@ export default {
allApiKeys: 'All API Keys',
timeRange: 'Time Range',
exportCsv: 'Export CSV',
exportExcel: 'Export Excel',
exportingProgress: 'Exporting data...',
exportedCount: 'Exported {current}/{total} records',
estimatedTime: 'Estimated time remaining: {time}',
cancelExport: 'Cancel Export',
exportCancelled: 'Export cancelled',
exporting: 'Exporting...',
preparingExport: 'Preparing export...',
model: 'Model',
@@ -368,6 +375,8 @@ export default {
noDataToExport: 'No data to export',
exportSuccess: 'Usage data exported successfully',
exportFailed: 'Failed to export usage data',
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
exportExcelFailed: 'Failed to export usage data',
billingType: 'Billing',
balance: 'Balance',
subscription: 'Subscription'
@@ -1291,6 +1300,7 @@ export default {
account: 'Account',
group: 'Group',
requestId: 'Request ID',
requestIdCopied: 'Request ID copied',
allModels: 'All Models',
allAccounts: 'All Accounts',
allGroups: 'All Groups',
@@ -1300,6 +1310,10 @@ export default {
outputCost: 'Output Cost',
cacheCreationCost: 'Cache Creation Cost',
cacheReadCost: 'Cache Read Cost',
inputTokens: 'Input Tokens',
outputTokens: 'Output Tokens',
cacheCreationTokens: 'Cache Creation Tokens',
cacheReadTokens: 'Cache Read Tokens',
failedToLoad: 'Failed to load usage records'
},
@@ -1489,5 +1503,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>'
}
}
}
}

View File

@@ -322,7 +322,8 @@ export default {
customKeyHint: '仅允许字母、数字、下划线和连字符最少16个字符。',
customKeyTooShort: '自定义密钥至少需要16个字符',
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
customKeyRequired: '请输入自定义密钥'
customKeyRequired: '请输入自定义密钥',
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。'
},
// Usage
@@ -341,6 +342,12 @@ export default {
allApiKeys: '全部密钥',
timeRange: '时间范围',
exportCsv: '导出 CSV',
exportExcel: '导出 Excel',
exportingProgress: '正在导出数据...',
exportedCount: '已导出 {current}/{total} 条',
estimatedTime: '预计剩余时间:{time}',
cancelExport: '取消导出',
exportCancelled: '导出已取消',
exporting: '导出中...',
preparingExport: '正在准备导出...',
model: '模型',
@@ -364,6 +371,8 @@ export default {
noDataToExport: '没有可导出的数据',
exportSuccess: '使用数据导出成功',
exportFailed: '使用数据导出失败',
exportExcelSuccess: '使用数据导出成功Excel格式',
exportExcelFailed: '使用数据导出失败',
billingType: '消费类型',
balance: '余额',
subscription: '订阅'
@@ -1490,6 +1499,7 @@ export default {
account: '账户',
group: '分组',
requestId: '请求ID',
requestIdCopied: '请求ID已复制',
allModels: '全部模型',
allAccounts: '全部账户',
allGroups: '全部分组',
@@ -1499,6 +1509,10 @@ export default {
outputCost: '输出成本',
cacheCreationCost: '缓存创建成本',
cacheReadCost: '缓存读取成本',
inputTokens: '输入 Token',
outputTokens: '输出 Token',
cacheCreationTokens: '缓存创建 Token',
cacheReadTokens: '缓存读取 Token',
failedToLoad: '加载使用记录失败'
},
@@ -1685,5 +1699,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>'
}
}
}
}

View File

@@ -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'

View 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
}
})

View File

@@ -79,6 +79,20 @@
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
}
.btn-success {
@apply bg-gradient-to-r from-emerald-500 to-emerald-600;
@apply text-white shadow-md shadow-emerald-500/25;
@apply hover:from-emerald-600 hover:to-emerald-700 hover:shadow-lg hover:shadow-emerald-500/30;
@apply dark:shadow-emerald-500/20;
}
.btn-warning {
@apply bg-gradient-to-r from-amber-500 to-amber-600;
@apply text-white shadow-md shadow-amber-500/25;
@apply hover:from-amber-600 hover:to-amber-700 hover:shadow-lg hover:shadow-amber-500/30;
@apply dark:shadow-amber-500/20;
}
.btn-sm {
@apply rounded-lg px-3 py-1.5 text-xs;
}
@@ -130,6 +144,20 @@
-moz-appearance: textfield;
}
/* ============ 玻璃效果 ============ */
.glass {
@apply bg-white/80 backdrop-blur-xl dark:bg-dark-800/80;
}
.glass-card {
@apply bg-white/70 dark:bg-dark-800/70;
@apply backdrop-blur-xl;
@apply rounded-2xl;
@apply border border-white/20 dark:border-dark-700/50;
@apply shadow-glass;
@apply transition-all duration-300;
}
/* ============ 卡片样式 ============ */
.card {
@apply bg-white dark:bg-dark-800/50;
@@ -151,6 +179,20 @@
@apply shadow-glass;
}
.card-header {
@apply border-b border-gray-100 dark:border-dark-700;
@apply px-6 py-4;
}
.card-body {
@apply p-6;
}
.card-footer {
@apply border-t border-gray-100 dark:border-dark-700;
@apply px-6 py-4;
}
/* ============ 统计卡片 ============ */
.stat-card {
@apply card p-5;
@@ -256,6 +298,10 @@
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
}
.badge-purple {
@apply bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400;
}
/* ============ 下拉菜单 ============ */
.dropdown {
@apply absolute z-50;
@@ -283,15 +329,19 @@
}
.modal-content {
@apply w-full;
@apply max-h-[95vh] sm:max-h-[90vh];
@apply bg-white dark:bg-dark-800;
@apply rounded-2xl shadow-2xl;
@apply w-full;
@apply max-h-[90vh] overflow-y-auto;
@apply border border-gray-200 dark:border-dark-700;
@apply flex flex-col;
}
.modal-header {
@apply border-b border-gray-100 px-6 py-4 dark:border-dark-700;
@apply border-b border-gray-200 px-4 py-3 dark:border-dark-700;
@apply sm:px-6 sm:py-4;
@apply flex items-center justify-between;
@apply flex-shrink-0;
}
.modal-title {
@@ -299,12 +349,69 @@
}
.modal-body {
@apply px-6 py-4;
@apply px-4 py-3;
@apply sm:px-6 sm:py-4;
@apply flex-1 overflow-y-auto;
}
.modal-footer {
@apply border-t border-gray-100 px-6 py-4 dark:border-dark-700;
@apply border-t border-gray-200 px-4 py-3 dark:border-dark-700;
@apply sm:px-6 sm:py-4;
@apply flex items-center justify-end gap-3;
@apply flex-shrink-0;
}
/* 防止body滚动的工具类 */
body.modal-open {
overflow: hidden;
}
.modal-enter-active {
transition: opacity 250ms ease-out;
}
.modal-leave-active {
transition: opacity 200ms ease-in;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .modal-content {
transition: transform 250ms ease-out, opacity 250ms ease-out;
}
.modal-leave-active .modal-content {
transition: transform 200ms ease-in, opacity 200ms ease-in;
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: scale(0.95);
opacity: 0;
}
.modal-enter-to .modal-content,
.modal-leave-from .modal-content {
transform: scale(1);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.modal-enter-active,
.modal-leave-active,
.modal-enter-active .modal-content,
.modal-leave-active .modal-content {
transition-duration: 1ms;
transition-delay: 0ms;
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: none;
}
}
/* ============ Dialog ============ */
@@ -518,6 +625,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 {

View File

@@ -0,0 +1,228 @@
/* 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;
/*
* 关键修复:让 overlay 不拦截点击事件
* 因为已设置 allowClose: false用户不能通过点击遮罩关闭引导
* 这样 Select 下拉菜单等脱离高亮区域的元素才能正常交互
* 视觉遮罩效果保持不变SVG 仍然渲染pointer-events 只影响交互不影响渲染)
*/
pointer-events: none !important;
}
.driver-overlay svg {
pointer-events: none !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; }
/* 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; }

View File

@@ -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"
@@ -105,65 +105,65 @@
<template #table>
<!-- Bulk Actions Bar -->
<div
v-if="selectedAccountIds.length > 0"
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
</span>
<button
@click="selectCurrentPageAccounts"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
</button>
<span class="text-gray-300 dark:text-primary-800"></span>
<button
@click="selectedAccountIds = []"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
{{ t('admin.accounts.bulkActions.clear') }}
</button>
</div>
<div class="flex items-center gap-2">
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
<div
v-if="selectedAccountIds.length > 0"
class="mb-[5px] mt-[10px] px-5 py-1"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
</span>
<button
@click="selectCurrentPageAccounts"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
{{ t('admin.accounts.bulkActions.delete') }}
</button>
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
</button>
<span class="text-gray-300 dark:text-primary-800"></span>
<button
@click="selectedAccountIds = []"
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
{{ t('admin.accounts.bulkActions.edit') }}
</button>
{{ t('admin.accounts.bulkActions.clear') }}
</button>
</div>
<div class="flex items-center gap-2">
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
{{ t('admin.accounts.bulkActions.delete') }}
</button>
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
{{ t('admin.accounts.bulkActions.edit') }}
</button>
</div>
</div>
</div>
</div>
<DataTable :columns="columns" :data="accounts" :loading="loading">
<template #cell-select="{ row }">
@@ -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[]>(() => {

View File

@@ -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
}

View File

@@ -300,8 +300,8 @@
<button @click="resetFilters" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<button @click="exportToCSV" class="btn btn-primary">
{{ t('usage.exportCsv') }}
<button @click="exportToExcel" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
</div>
</div>
@@ -361,90 +361,114 @@
</template>
<template #cell-tokens="{ row }">
<div class="space-y-1.5 text-sm">
<!-- Input / Output Tokens -->
<div class="flex items-center gap-2">
<!-- Input -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-emerald-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.input_tokens.toLocaleString()
}}</span>
<div class="flex items-center gap-1.5">
<div class="space-y-1.5 text-sm">
<!-- Input / Output Tokens -->
<div class="flex items-center gap-2">
<!-- Input -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-emerald-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.input_tokens.toLocaleString()
}}</span>
</div>
<!-- Output -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-violet-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.output_tokens.toLocaleString()
}}</span>
</div>
</div>
<!-- Output -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-violet-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.output_tokens.toLocaleString()
}}</span>
<!-- Cache Tokens (Read + Write) -->
<div
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class="flex items-center gap-2"
>
<!-- Cache Read -->
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-sky-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{
formatCacheTokens(row.cache_read_tokens)
}}</span>
</div>
<!-- Cache Write -->
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{
formatCacheTokens(row.cache_creation_tokens)
}}</span>
</div>
</div>
</div>
<!-- Cache Tokens (Read + Write) -->
<!-- Token Detail Tooltip -->
<div
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class="flex items-center gap-2"
class="group relative"
@mouseenter="showTokenTooltip($event, row)"
@mouseleave="hideTokenTooltip"
>
<!-- Cache Read -->
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<div
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class="h-3.5 w-3.5 text-sky-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{
formatCacheTokens(row.cache_read_tokens)
}}</span>
</div>
<!-- Cache Write -->
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{
formatCacheTokens(row.cache_creation_tokens)
}}</span>
</div>
</div>
</div>
@@ -516,9 +540,50 @@
</template>
<template #cell-request_id="{ row }">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
row.request_id || '-'
}}</span>
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
<span
class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate"
:title="row.request_id"
>
{{ row.request_id }}
</span>
<button
@click="copyRequestId(row.request_id)"
class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class="
copiedRequestId === row.request_id
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
"
:title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"
>
<svg
v-if="copiedRequestId === row.request_id"
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<svg
v-else
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty>
@@ -540,6 +605,63 @@
</div>
</AppLayout>
<ExportProgressDialog
:show="exportProgress.show"
:progress="exportProgress.progress"
:current="exportProgress.current"
:total="exportProgress.total"
:estimated-time="exportProgress.estimatedTime"
@cancel="cancelExport"
/>
<!-- Token Tooltip Portal -->
<Teleport to="body">
<div
v-if="tokenTooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div class="space-y-1.5">
<!-- Token Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
<!-- Total -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
<!-- Tooltip Portal -->
<Teleport to="body">
<div
@@ -602,10 +724,14 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import * as XLSX from 'xlsx'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import { adminUsageAPI } from '@/api/admin/usage'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
@@ -615,6 +741,7 @@ import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
import type { Column } from '@/components/common/types'
import type {
@@ -626,12 +753,21 @@ import type {
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
// Tooltip state
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Token tooltip state
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null)
// Request ID copy state
const copiedRequestId = ref<string | null>(null)
// Usage stats from API
const usageStats = ref<AdminUsageStatsResponse | null>(null)
@@ -657,6 +793,7 @@ const columns = computed<Column[]>(() => [
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
@@ -669,6 +806,15 @@ const accounts = ref<any[]>([])
const groups = ref<any[]>([])
const loading = ref(false)
let abortController: AbortController | null = null
let exportAbortController: AbortController | null = null
const exporting = ref(false)
const exportProgress = reactive({
show: false,
progress: 0,
current: 0,
total: 0,
estimatedTime: ''
})
// User search state
const userSearchKeyword = ref('')
@@ -868,6 +1014,16 @@ const formatCacheTokens = (value: number): string => {
return value.toLocaleString()
}
const copyRequestId = async (requestId: string) => {
const success = await clipboardCopy(requestId, t('admin.usage.requestIdCopied'))
if (success) {
copiedRequestId.value = requestId
setTimeout(() => {
copiedRequestId.value = null
}, 800)
}
}
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === 'AbortError') {
return true
@@ -879,6 +1035,40 @@ const isAbortError = (error: unknown): boolean => {
return false
}
const formatExportTimestamp = (date: Date): string => {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
}
const formatRemainingTime = (ms: number): string => {
const totalSeconds = Math.max(0, Math.round(ms / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts = []
if (hours > 0) {
parts.push(`${hours}h`)
}
if (minutes > 0 || hours > 0) {
parts.push(`${minutes}m`)
}
parts.push(`${seconds}s`)
return parts.join(' ')
}
const updateExportProgress = (current: number, total: number, startedAt: number) => {
exportProgress.current = current
exportProgress.total = total
exportProgress.progress = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0
if (current > 0 && total > 0) {
const elapsedMs = Date.now() - startedAt
const remainingMs = Math.max(0, Math.round((elapsedMs / current) * (total - current)))
exportProgress.estimatedTime = formatRemainingTime(remainingMs)
} else {
exportProgress.estimatedTime = ''
}
}
const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
@@ -1051,52 +1241,129 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsageLogs()
}
const exportToCSV = () => {
if (usageLogs.value.length === 0) {
const cancelExport = () => {
if (!exporting.value) {
return
}
exportAbortController?.abort()
}
const exportToExcel = async () => {
if (pagination.value.total === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = [
'User',
'API Key',
'Model',
'Type',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
'Cache Write Tokens',
'Total Cost',
'Billing Type',
'Duration (ms)',
'Time'
]
const rows = usageLogs.value.map((log) => [
log.user?.email || '',
log.api_key?.name || '',
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.total_cost.toFixed(6),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.duration_ms,
log.created_at
])
if (exporting.value) {
return
}
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
exporting.value = true
exportProgress.show = true
exportProgress.progress = 0
exportProgress.current = 0
exportProgress.total = pagination.value.total
exportProgress.estimatedTime = ''
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
link.click()
window.URL.revokeObjectURL(url)
const startedAt = Date.now()
const controller = new AbortController()
exportAbortController = controller
appStore.showSuccess(t('usage.exportSuccess'))
try {
const allLogs: UsageLog[] = []
const pageSize = 100
let page = 1
let total = pagination.value.total
while (true) {
const params: AdminUsageQueryParams = {
page,
page_size: pageSize,
...filters.value
}
const response = await adminUsageAPI.list(params, { signal: controller.signal })
if (controller.signal.aborted) {
break
}
if (page === 1) {
total = response.total
exportProgress.total = total
}
if (response.items?.length) {
allLogs.push(...response.items)
}
updateExportProgress(allLogs.length, total, startedAt)
if (allLogs.length >= total || response.items.length < pageSize) {
break
}
page += 1
}
if (controller.signal.aborted) {
appStore.showInfo(t('usage.exportCancelled'))
return
}
if (allLogs.length === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = [
'User',
'API Key',
'Model',
'Type',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
'Cache Write Tokens',
'Total Cost',
'Billing Type',
'Duration (ms)',
'Time'
]
const rows = allLogs.map((log) => [
log.user?.email || '',
log.api_key?.name || '',
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
Number(log.total_cost.toFixed(6)),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.duration_ms,
log.created_at
])
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows])
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'Usage')
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
saveAs(blob, `admin_usage_${formatExportTimestamp(new Date())}.xlsx`)
appStore.showSuccess(t('usage.exportExcelSuccess'))
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) {
appStore.showInfo(t('usage.exportCancelled'))
return
}
appStore.showError(t('usage.exportExcelFailed'))
console.error('Excel export failed:', error)
} finally {
if (exportAbortController === controller) {
exportAbortController = null
}
exporting.value = false
exportProgress.show = false
}
}
// Click outside to close dropdown
@@ -1123,6 +1390,22 @@ const hideTooltip = () => {
tooltipData.value = null
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
tokenTooltipPosition.value.x = rect.right + 8
tokenTooltipPosition.value.y = rect.top + rect.height / 2
tokenTooltipVisible.value = true
}
const hideTokenTooltip = () => {
tokenTooltipVisible.value = false
tokenTooltipData.value = null
}
onMounted(() => {
loadFilterOptions()
loadApiKeys()
@@ -1140,5 +1423,8 @@ onUnmounted(() => {
if (abortController) {
abortController.abort()
}
if (exportAbortController) {
exportAbortController.abort()
}
})
</script>

View File

@@ -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"
@@ -301,7 +301,7 @@
<BaseDialog
:show="showCreateModal || showEditModal"
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
width="narrow"
width="normal"
@close="closeModals"
>
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
@@ -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
}
@@ -885,7 +900,20 @@ const importToCcswitch = (apiKey: string) => {
usageAutoInterval: '30'
})
const deeplink = `ccswitch://v1/import?${params.toString()}`
window.open(deeplink, '_self')
try {
window.open(deeplink, '_self')
// Check if the protocol handler worked by detecting if we're still focused
setTimeout(() => {
if (document.hasFocus()) {
// Still focused means the protocol handler likely failed
appStore.showError(t('keys.ccSwitchNotInstalled'))
}
}, 100)
} catch (error) {
appStore.showError(t('keys.ccSwitchNotInstalled'))
}
}
onMounted(() => {

View File

@@ -219,90 +219,114 @@
</template>
<template #cell-tokens="{ row }">
<div class="space-y-1.5 text-sm">
<!-- Input / Output Tokens -->
<div class="flex items-center gap-2">
<!-- Input -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-emerald-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.input_tokens.toLocaleString()
}}</span>
<div class="flex items-center gap-1.5">
<div class="space-y-1.5 text-sm">
<!-- Input / Output Tokens -->
<div class="flex items-center gap-2">
<!-- Input -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-emerald-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.input_tokens.toLocaleString()
}}</span>
</div>
<!-- Output -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-violet-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.output_tokens.toLocaleString()
}}</span>
</div>
</div>
<!-- Output -->
<div class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-violet-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{
row.output_tokens.toLocaleString()
}}</span>
<!-- Cache Tokens (Read + Write) -->
<div
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class="flex items-center gap-2"
>
<!-- Cache Read -->
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-sky-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{
formatCacheTokens(row.cache_read_tokens)
}}</span>
</div>
<!-- Cache Write -->
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{
formatCacheTokens(row.cache_creation_tokens)
}}</span>
</div>
</div>
</div>
<!-- Cache Tokens (Read + Write) -->
<!-- Token Detail Tooltip -->
<div
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class="flex items-center gap-2"
class="group relative"
@mouseenter="showTokenTooltip($event, row)"
@mouseleave="hideTokenTooltip"
>
<!-- Cache Read -->
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<div
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class="h-3.5 w-3.5 text-sky-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{
formatCacheTokens(row.cache_read_tokens)
}}</span>
</div>
<!-- Cache Write -->
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg
class="h-3.5 w-3.5 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{
formatCacheTokens(row.cache_creation_tokens)
}}</span>
</div>
</div>
</div>
@@ -392,6 +416,54 @@
</TablePageLayout>
</AppLayout>
<!-- Token Tooltip Portal -->
<Teleport to="body">
<div
v-if="tokenTooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div class="space-y-1.5">
<!-- Token Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
<!-- Total -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
<!-- Tooltip Portal -->
<Teleport to="body">
<div
@@ -458,6 +530,11 @@ const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Token tooltip state
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null)
// Usage stats from API
const usageStats = ref<UsageStatsResponse | null>(null)
@@ -778,6 +855,22 @@ const hideTooltip = () => {
tooltipData.value = null
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
tokenTooltipPosition.value.x = rect.right + 8
tokenTooltipPosition.value.y = rect.top + rect.height / 2
tokenTooltipVisible.value = true
}
const hideTokenTooltip = () => {
tokenTooltipVisible.value = false
tokenTooltipData.value = null
}
onMounted(() => {
loadApiKeys()
loadUsageLogs()