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