Commit 942c3e15 authored by song's avatar song
Browse files

Merge branch 'main' into feature/antigravity_auth_image

parents caa8c47b c328b741
<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,11 +29,16 @@ ...@@ -29,11 +29,16 @@
</span> </span>
</button> </button>
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<Teleport to="body">
<Transition name="select-dropdown"> <Transition name="select-dropdown">
<div <div
v-if="isOpen" v-if="isOpen"
ref="dropdownRef" ref="dropdownRef"
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']" class="select-dropdown-portal"
:style="dropdownStyle"
@click.stop
@mousedown.stop
> >
<!-- Search input --> <!-- Search input -->
<div v-if="searchable" class="select-search"> <div v-if="searchable" class="select-search">
...@@ -65,7 +70,7 @@ ...@@ -65,7 +70,7 @@
<div <div
v-for="option in filteredOptions" v-for="option in filteredOptions"
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`" :key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click="selectOption(option)" @click.stop="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']" :class="['select-option', isSelected(option) && 'select-option-selected']"
> >
<slot name="option" :option="option" :selected="isSelected(option)"> <slot name="option" :option="option" :selected="isSelected(option)">
...@@ -90,6 +95,7 @@ ...@@ -90,6 +95,7 @@
</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
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
if (target.closest('.select-dropdown-portal')) {
return // 点击在下拉菜单内,不关闭
}
// 检查是否点击在触发器内
if (containerRef.value && containerRef.value.contains(target)) {
return // 点击在触发器内,让 toggle 处理
}
// 点击在外部,关闭下拉菜单
isOpen.value = false isOpen.value = false
searchQuery.value = '' 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.
This diff is collapsed.
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useAppStore } from './app' export { useAppStore } from './app'
export { useSubscriptionStore } from './subscriptions' export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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