Commit b63b338e authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'main' into test-dev

parents 57db688d e85b35c6
This diff is collapsed.
...@@ -14,13 +14,17 @@ ...@@ -14,13 +14,17 @@
"@vueuse/core": "^10.7.0", "@vueuse/core": "^10.7.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"driver.js": "^1.4.0",
"file-saver": "^2.0.5",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-chartjs": "^5.3.0", "vue-chartjs": "^5.3.0",
"vue-i18n": "^9.14.5", "vue-i18n": "^9.14.5",
"vue-router": "^4.2.5" "vue-router": "^4.2.5",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/file-saver": "^2.0.7",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
......
This diff is collapsed.
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']
}
}
]
...@@ -362,6 +362,10 @@ const resetState = () => { ...@@ -362,6 +362,10 @@ const resetState = () => {
} }
const handleClose = () => { const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource() closeEventSource()
emit('close') emit('close')
} }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<BaseDialog <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.createAccount')" :title="t('admin.accounts.createAccount')"
width="wide" width="normal"
@close="handleClose" @close="handleClose"
> >
<!-- Step Indicator for OAuth accounts --> <!-- Step Indicator for OAuth accounts -->
...@@ -53,13 +53,14 @@ ...@@ -53,13 +53,14 @@
required required
class="input" class="input"
:placeholder="t('admin.accounts.enterAccountName')" :placeholder="t('admin.accounts.enterAccountName')"
data-tour="account-form-name"
/> />
</div> </div>
<!-- Platform Selection - Segmented Control Style --> <!-- Platform Selection - Segmented Control Style -->
<div> <div>
<label class="input-label">{{ t('admin.accounts.platform') }}</label> <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 <button
type="button" type="button"
@click="form.platform = 'anthropic'" @click="form.platform = 'anthropic'"
...@@ -141,7 +142,7 @@ ...@@ -141,7 +142,7 @@
<!-- Account Type Selection (Anthropic) --> <!-- Account Type Selection (Anthropic) -->
<div v-if="form.platform === 'anthropic'"> <div v-if="form.platform === 'anthropic'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <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 <button
type="button" type="button"
@click="accountCategory = 'oauth-based'" @click="accountCategory = 'oauth-based'"
...@@ -231,7 +232,7 @@ ...@@ -231,7 +232,7 @@
<!-- Account Type Selection (OpenAI) --> <!-- Account Type Selection (OpenAI) -->
<div v-if="form.platform === 'openai'"> <div v-if="form.platform === 'openai'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <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 <button
type="button" type="button"
@click="accountCategory = 'oauth-based'" @click="accountCategory = 'oauth-based'"
...@@ -313,7 +314,7 @@ ...@@ -313,7 +314,7 @@
<!-- Account Type Selection (Gemini) --> <!-- Account Type Selection (Gemini) -->
<div v-if="form.platform === 'gemini'"> <div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <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 <button
type="button" type="button"
@click="accountCategory = 'oauth-based'" @click="accountCategory = 'oauth-based'"
...@@ -959,18 +960,21 @@ ...@@ -959,18 +960,21 @@
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label> <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> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
</div> </div>
<!-- Group Selection - 仅标准模式显示 --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector <div v-if="!authStore.isSimpleMode" data-tour="account-form-groups">
v-if="!authStore.isSimpleMode" <GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
v-model="form.group_ids" </div>
:groups="groups"
:platform="form.platform"
/>
</form> </form>
...@@ -1005,6 +1009,7 @@ ...@@ -1005,6 +1009,7 @@
form="create-account-form" form="create-account-form"
:disabled="submitting" :disabled="submitting"
class="btn btn-primary" class="btn btn-primary"
data-tour="account-form-submit"
> >
<svg <svg
v-if="submitting" v-if="submitting"
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<BaseDialog <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.editAccount')" :title="t('admin.accounts.editAccount')"
width="wide" width="normal"
@close="handleClose" @close="handleClose"
> >
<form <form
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
> >
<div> <div>
<label class="input-label">{{ t('common.name') }}</label> <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> </div>
<!-- API Key fields (only for apikey type) --> <!-- API Key fields (only for apikey type) -->
...@@ -457,7 +457,13 @@ ...@@ -457,7 +457,13 @@
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label> <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>
</div> </div>
...@@ -467,12 +473,9 @@ ...@@ -467,12 +473,9 @@
</div> </div>
<!-- Group Selection - 仅标准模式显示 --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector <div v-if="!authStore.isSimpleMode" data-tour="account-form-groups">
v-if="!authStore.isSimpleMode" <GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
v-model="form.group_ids" </div>
:groups="groups"
:platform="account?.platform"
/>
</form> </form>
...@@ -486,6 +489,7 @@ ...@@ -486,6 +489,7 @@
form="edit-account-form" form="edit-account-form"
:disabled="submitting" :disabled="submitting"
class="btn btn-primary" class="btn btn-primary"
data-tour="account-form-submit"
> >
<svg <svg
v-if="submitting" v-if="submitting"
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<BaseDialog <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.reAuthorizeAccount')" :title="t('admin.accounts.reAuthorizeAccount')"
width="wide" width="normal"
@close="handleClose" @close="handleClose"
> >
<div v-if="account" class="space-y-4"> <div v-if="account" class="space-y-4">
......
...@@ -151,6 +151,10 @@ watch( ...@@ -151,6 +151,10 @@ watch(
) )
const handleClose = () => { const handleClose = () => {
// 防止在同步进行中关闭对话框
if (syncing.value) {
return
}
emit('close') emit('close')
} }
......
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div <Transition name="modal">
v-if="show" <div
class="modal-overlay" v-if="show"
aria-labelledby="modal-title" class="modal-overlay"
role="dialog" :aria-labelledby="dialogId"
aria-modal="true" role="dialog"
@click.self="handleClose" aria-modal="true"
> @click.self="handleClose"
<!-- Modal panel --> >
<div :class="['modal-content', widthClasses]" @click.stop> <!-- Modal panel -->
<!-- Header --> <div ref="dialogRef" :class="['modal-content', widthClasses]" @click.stop>
<div class="modal-header"> <!-- Header -->
<h3 id="modal-title" class="modal-title"> <div class="modal-header">
{{ title }} <h3 :id="dialogId" class="modal-title">
</h3> {{ title }}
<button </h3>
@click="emit('close')" <button
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" @click="emit('close')"
aria-label="Close modal" 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
</svg> class="h-5 w-5"
</button> fill="none"
</div> 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 --> <!-- Body -->
<div class="modal-body"> <div class="modal-body">
<slot></slot> <slot></slot>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div v-if="$slots.footer" class="modal-footer"> <div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot> <slot name="footer"></slot>
</div>
</div> </div>
</div> </div>
</div> </Transition>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <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' type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
...@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const widthClasses = computed(() => { 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> = { const widths: Record<DialogWidth, string> = {
narrow: 'max-w-md', narrow: 'max-w-md',
normal: 'max-w-lg', normal: 'max-w-lg',
wide: 'max-w-4xl', wide: 'w-full sm:max-w-2xl md:max-w-3xl lg:max-w-4xl',
'extra-wide': 'max-w-6xl', 'extra-wide': 'w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl',
full: 'max-w-7xl' full: 'w-full sm:max-w-4xl md:max-w-5xl lg:max-w-6xl xl:max-w-7xl'
} }
return widths[props.width] return widths[props.width]
}) })
...@@ -94,14 +107,31 @@ const handleEscape = (event: KeyboardEvent) => { ...@@ -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( watch(
() => props.show, () => props.show,
(isOpen) => { async (isOpen) => {
if (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 { } else {
document.body.style.overflow = '' document.body.classList.remove('modal-open')
// 恢复之前的焦点
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
previousActiveElement.focus()
}
previousActiveElement = null
} }
}, },
{ immediate: true } { immediate: true }
...@@ -113,6 +143,7 @@ onMounted(() => { ...@@ -113,6 +143,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleEscape) document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = '' // 确保组件卸载时移除滚动锁定
document.body.classList.remove('modal-open')
}) })
</script> </script>
<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>
<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>
...@@ -29,67 +29,73 @@ ...@@ -29,67 +29,73 @@
</span> </span>
</button> </button>
<Transition name="select-dropdown"> <!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<div <Teleport to="body">
v-if="isOpen" <Transition name="select-dropdown">
ref="dropdownRef" <div
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']" v-if="isOpen"
> ref="dropdownRef"
<!-- Search input --> class="select-dropdown-portal"
<div v-if="searchable" class="select-search"> :style="dropdownStyle"
<svg @click.stop
class="h-4 w-4 text-gray-400" @mousedown.stop
fill="none" >
stroke="currentColor" <!-- Search input -->
viewBox="0 0 24 24" <div v-if="searchable" class="select-search">
stroke-width="1.5" <svg
> class="h-4 w-4 text-gray-400"
<path fill="none"
stroke-linecap="round" stroke="currentColor"
stroke-linejoin="round" viewBox="0 0 24 24"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" 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> </div>
<!-- Empty state --> <!-- Options list -->
<div v-if="filteredOptions.length === 0" class="select-empty"> <div class="select-options">
{{ emptyTextDisplay }} <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> </div>
</div> </Transition>
</Transition> </Teleport>
</div> </div>
</template> </template>
...@@ -147,6 +153,28 @@ const containerRef = ref<HTMLElement | null>(null) ...@@ -147,6 +153,28 @@ const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom') 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 = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
...@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean => ...@@ -193,14 +221,17 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
const calculateDropdownPosition = () => { const calculateDropdownPosition = () => {
if (!containerRef.value) return if (!containerRef.value) return
// Update trigger rect for positioning
triggerRect.value = containerRef.value.getBoundingClientRect()
nextTick(() => { nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return 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 dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = triggerRect.top const spaceAbove = rect.top
// If not enough space below but enough space above, show dropdown on top // If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) { if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
...@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => { ...@@ -233,10 +264,21 @@ const selectOption = (option: SelectOption | Record<string, unknown>) => {
} }
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) { const target = event.target as HTMLElement
isOpen.value = false
searchQuery.value = '' // 使用 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) => { const handleEscape = (event: KeyboardEvent) => {
...@@ -295,54 +337,57 @@ onUnmounted(() => { ...@@ -295,54 +337,57 @@ onUnmounted(() => {
.select-icon { .select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400; @apply flex-shrink-0 text-gray-400 dark:text-dark-400;
} }
</style>
.select-dropdown { <!-- Global styles for teleported dropdown -->
@apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px]; <style>
.select-dropdown-portal {
@apply w-max max-w-[300px];
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30; @apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden; @apply overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events: auto !important;
} }
.select-dropdown-top { .select-dropdown-portal .select-search {
@apply bottom-full mb-2 mt-0;
}
.select-search {
@apply flex items-center gap-2 px-3 py-2; @apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700; @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 flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400; @apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none; @apply focus:outline-none;
} }
.select-options { .select-dropdown-portal .select-options {
@apply max-h-60 overflow-y-auto py-1; @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 flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm; @apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300; @apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150; @apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700; @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 bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300; @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; @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 px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400; @apply text-gray-500 dark:text-dark-400;
} }
...@@ -356,17 +401,6 @@ onUnmounted(() => { ...@@ -356,17 +401,6 @@ onUnmounted(() => {
.select-dropdown-enter-from, .select-dropdown-enter-from,
.select-dropdown-leave-to { .select-dropdown-leave-to {
opacity: 0; 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); 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> </style>
// Export all common components // Export all common components
export { default as DataTable } from './DataTable.vue' export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.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 BaseDialog } from './BaseDialog.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue' export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue' export { default as StatCard } from './StatCard.vue'
...@@ -9,6 +8,7 @@ export { default as Toast } from './Toast.vue' ...@@ -9,6 +8,7 @@ export { default as Toast } from './Toast.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue' export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue' export { default as EmptyState } from './EmptyState.vue'
export { default as LocaleSwitcher } from './LocaleSwitcher.vue' export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
export { default as ExportProgressDialog } from './ExportProgressDialog.vue'
// Export types // Export types
export type { Column } from './types' export type { Column } from './types'
...@@ -199,6 +199,17 @@ ...@@ -199,6 +199,17 @@
</div> </div>
</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"> <div class="border-t border-gray-100 py-1 dark:border-dark-700">
<button <button
@click="handleLogout" @click="handleLogout"
...@@ -232,7 +243,7 @@ ...@@ -232,7 +243,7 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore } from '@/stores' import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue' import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
...@@ -241,12 +252,18 @@ const route = useRoute() ...@@ -241,12 +252,18 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const dropdownOpen = ref(false) const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo) const contactInfo = computed(() => appStore.contactInfo)
// 只在标准模式的管理员下显示新手引导按钮
const showOnboardingButton = computed(() => {
return !authStore.isSimpleMode && user.value?.role === 'admin'
})
const userInitials = computed(() => { const userInitials = computed(() => {
if (!user.value) return '' if (!user.value) return ''
// Prefer username, fallback to email // Prefer username, fallback to email
...@@ -300,6 +317,11 @@ async function handleLogout() { ...@@ -300,6 +317,11 @@ async function handleLogout() {
await router.push('/login') await router.push('/login')
} }
function handleReplayGuide() {
closeDropdown()
onboardingStore.replay()
}
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) { if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
closeDropdown() closeDropdown()
......
...@@ -23,11 +23,30 @@ ...@@ -23,11 +23,30 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import '@/styles/onboarding.css'
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores' 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 AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed) 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> </script>
...@@ -36,7 +36,16 @@ ...@@ -36,7 +36,16 @@
class="sidebar-link mb-1" class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }" :class="{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined" :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" /> <component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
...@@ -59,7 +68,8 @@ ...@@ -59,7 +68,8 @@
class="sidebar-link mb-1" class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }" :class="{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined" :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" /> <component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
...@@ -79,7 +89,8 @@ ...@@ -79,7 +89,8 @@
class="sidebar-link mb-1" class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }" :class="{ 'sidebar-link-active': isActive(item.path) }"
:title="sidebarCollapsed ? item.label : undefined" :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" /> <component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade"> <transition name="fade">
...@@ -136,7 +147,7 @@ ...@@ -136,7 +147,7 @@
import { computed, h, ref } from 'vue' import { computed, h, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore } from '@/stores' import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue' import VersionBadge from '@/components/common/VersionBadge.vue'
const { t } = useI18n() const { t } = useI18n()
...@@ -144,6 +155,7 @@ const { t } = useI18n() ...@@ -144,6 +155,7 @@ const { t } = useI18n()
const route = useRoute() const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed) const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const mobileOpen = computed(() => appStore.mobileOpen) const mobileOpen = computed(() => appStore.mobileOpen)
...@@ -465,12 +477,24 @@ function closeMobile() { ...@@ -465,12 +477,24 @@ function closeMobile() {
appStore.setMobileOpen(false) appStore.setMobileOpen(false)
} }
function handleMenuItemClick() { function handleMenuItemClick(itemPath: string) {
if (mobileOpen.value) { if (mobileOpen.value) {
setTimeout(() => { setTimeout(() => {
appStore.setMobileOpen(false) appStore.setMobileOpen(false)
}, 150) }, 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 { function isActive(path: string): boolean {
......
This diff is collapsed.
...@@ -27,7 +27,10 @@ export const i18n = createI18n({ ...@@ -27,7 +27,10 @@ export const i18n = createI18n({
messages: { messages: {
en, en,
zh zh
} },
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
// 这些内容是内部定义的,不存在 XSS 风险
warnHtmlMessage: false
}) })
export function setLocale(locale: string) { export function setLocale(locale: string) {
......
This diff is collapsed.
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment