mirror of
https://github.com/Kuingsmile/PicList.git
synced 2026-05-17 17:27:35 +08:00
378 lines
11 KiB
Vue
378 lines
11 KiB
Vue
<template>
|
|
<TransitionRoot appear :show="isVisible" as="template">
|
|
<div class="pointer-events-auto fixed inset-0 z-9999">
|
|
<div class="absolute inset-0 bg-black/15 transition-all duration-300 ease-apple" @click="handleClose" />
|
|
|
|
<div
|
|
v-if="currentStepConfig.target"
|
|
class="pointer-events-none absolute z-10000 rounded-xl border-2 border-dashed border-accent shadow-sm transition-all duration-300 ease-apple"
|
|
:style="spotlightStyle"
|
|
/>
|
|
|
|
<!-- Guide Card -->
|
|
<div
|
|
class="absolute z-10001 max-h-[80vh] w-[420px] max-w-[90vw] overflow-auto rounded-lg border border-border bg-bg-tertiary shadow-2xl transition-all duration-300 ease-apple max-sm:w-[calc(100vw-32px)]"
|
|
:style="cardStyle"
|
|
>
|
|
<div class="flex items-center justify-between border-b border-b-border px-4 py-3">
|
|
<div class="flex flex-col gap-0.5">
|
|
<h3 class="m-0 text-[16px] font-semibold text-main">{{ t('guide.title') }}</h3>
|
|
<span class="text-sm text-tertiary">
|
|
{{ t('guide.stepIndicator', { current: currentStep + 1, total: steps.length }) }}
|
|
</span>
|
|
</div>
|
|
<button
|
|
class="flex cursor-pointer items-center justify-center rounded-sm border-none bg-transparent p-[4px] text-secondary transition-all duration-200 ease-apple hover:bg-accent-hover hover:text-main"
|
|
:title="t('guide.close')"
|
|
@click="handleClose"
|
|
>
|
|
<XIcon :size="20" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-start gap-4 px-5 py-4">
|
|
<div
|
|
class="border-lg flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-lg bg-accent text-white"
|
|
>
|
|
<component :is="currentStepConfig.icon" :size="24" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h4 class="mb-1 text-base font-semibold text-main">{{ t(currentStepConfig.title) }}</h4>
|
|
<p class="m-0 text-sm leading-[1.5] text-secondary">{{ t(currentStepConfig.description) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-t-border p-3">
|
|
<div class="mb-2.5 flex justify-center gap-1.5">
|
|
<div
|
|
v-for="(_, index) in steps"
|
|
:key="index"
|
|
class="h-[6px] w-[6px] rounded-full bg-border transition-all duration-300 ease-apple [.active]:w-5 [.active]:rounded-xs [.active]:bg-accent [.completed]:bg-success"
|
|
:class="{ active: index === currentStep, completed: index < currentStep }"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-1.5">
|
|
<CustomButton
|
|
v-if="currentStep > 0"
|
|
type="secondary"
|
|
:icon="ChevronLeftIcon"
|
|
:text="t('guide.previous')"
|
|
class="p-2!"
|
|
@click="handlePrevious"
|
|
/>
|
|
<CustomButton class="p-2!" type="secondary" :text="t('guide.skip')" @click="handleSkip" />
|
|
<CustomButton
|
|
v-if="currentStep < steps.length - 1"
|
|
type="primary"
|
|
class="p-2!"
|
|
:icon="ChevronRightIcon"
|
|
:text="t('guide.next')"
|
|
@click="handleNext"
|
|
/>
|
|
<CustomButton
|
|
v-else
|
|
type="primary"
|
|
class="p-2!"
|
|
:icon="CheckCircleIcon"
|
|
:text="t('guide.finish')"
|
|
@click="handleFinish"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TransitionRoot>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { TransitionRoot } from '@headlessui/vue'
|
|
import { useStorage } from '@vueuse/core'
|
|
import {
|
|
ArrowLeftRightIcon,
|
|
CheckCircleIcon,
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
HelpCircleIcon,
|
|
ImageIcon,
|
|
PaletteIcon,
|
|
UploadCloudIcon,
|
|
XIcon,
|
|
} from 'lucide-vue-next'
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import CustomButton from '@/components/common/CustomButton.vue'
|
|
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const hasSeenGuide = useStorage('has-seen-first-time-guide', false)
|
|
const isVisible = ref(false)
|
|
const currentStep = ref(0)
|
|
const spotlightRect = ref<DOMRect | null>(null)
|
|
|
|
interface GuideStep {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
additionalInfo?: string[]
|
|
target?: string
|
|
position?: 'top' | 'bottom' | 'left' | 'right' | 'center'
|
|
icon: any
|
|
action?: () => void
|
|
}
|
|
|
|
const steps: GuideStep[] = [
|
|
{
|
|
id: 'welcome',
|
|
title: 'guide.steps.welcome.title',
|
|
description: 'guide.steps.welcome.description',
|
|
position: 'center',
|
|
icon: HelpCircleIcon,
|
|
},
|
|
{
|
|
id: 'upload',
|
|
title: 'guide.steps.upload.title',
|
|
description: 'guide.steps.upload.description',
|
|
target: '#upload-area',
|
|
position: 'bottom',
|
|
icon: UploadCloudIcon,
|
|
},
|
|
{
|
|
id: 'picbed',
|
|
title: 'guide.steps.picbed.title',
|
|
description: 'guide.steps.picbed.description',
|
|
target: '.provider-button',
|
|
position: 'bottom',
|
|
icon: ArrowLeftRightIcon,
|
|
},
|
|
{
|
|
id: 'theme',
|
|
title: 'guide.steps.theme.title',
|
|
description: 'guide.steps.theme.description',
|
|
target: '.theme-switcher',
|
|
position: 'right',
|
|
icon: PaletteIcon,
|
|
},
|
|
{
|
|
id: 'themeSelection',
|
|
title: 'guide.steps.themeSelection.title',
|
|
description: 'guide.steps.themeSelection.description',
|
|
target: '.theme-dropdown',
|
|
position: 'bottom',
|
|
icon: PaletteIcon,
|
|
},
|
|
{
|
|
id: 'gallery',
|
|
title: 'guide.steps.gallery.title',
|
|
description: 'guide.steps.gallery.description',
|
|
target: 'nav .nav-item:nth-child(3)',
|
|
position: 'right',
|
|
icon: ImageIcon,
|
|
},
|
|
{
|
|
id: 'finish',
|
|
title: 'guide.steps.finish.title',
|
|
description: 'guide.steps.finish.description',
|
|
position: 'center',
|
|
icon: CheckCircleIcon,
|
|
},
|
|
]
|
|
|
|
const currentStepConfig = computed(() => steps[currentStep.value])
|
|
|
|
const updateSpotlight = async () => {
|
|
await nextTick()
|
|
const target = currentStepConfig.value.target
|
|
if (!target) {
|
|
spotlightRect.value = null
|
|
return
|
|
}
|
|
|
|
const element = document.querySelector(target)
|
|
if (element) {
|
|
spotlightRect.value = element.getBoundingClientRect()
|
|
} else {
|
|
spotlightRect.value = null
|
|
}
|
|
}
|
|
|
|
const spotlightStyle = computed(() => {
|
|
if (!spotlightRect.value) return {}
|
|
|
|
const padding = 8
|
|
return {
|
|
top: `${spotlightRect.value.top - padding}px`,
|
|
left: `${spotlightRect.value.left - padding}px`,
|
|
width: `${spotlightRect.value.width + padding * 2}px`,
|
|
height: `${spotlightRect.value.height + padding * 2}px`,
|
|
}
|
|
})
|
|
|
|
const cardStyle = computed(() => {
|
|
if (!spotlightRect.value || currentStepConfig.value.position === 'center') {
|
|
return {
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
}
|
|
}
|
|
|
|
const rect = spotlightRect.value
|
|
const position = currentStepConfig.value.position || 'bottom'
|
|
const offset = 16
|
|
const cardWidth = 420
|
|
const estimatedCardHeight = 260
|
|
const padding = 12
|
|
|
|
const style: Record<string, string> = {}
|
|
|
|
const centerX = rect.left + rect.width / 2
|
|
const halfCardWidth = cardWidth / 2
|
|
|
|
let adjustedCenterX = centerX
|
|
if (centerX - halfCardWidth < padding) {
|
|
adjustedCenterX = halfCardWidth + padding
|
|
} else if (centerX + halfCardWidth > window.innerWidth - padding) {
|
|
adjustedCenterX = window.innerWidth - halfCardWidth - padding
|
|
}
|
|
|
|
if (position === 'bottom') {
|
|
const spaceBelow = window.innerHeight - rect.bottom - offset - padding
|
|
const spaceAbove = rect.top - offset - padding
|
|
|
|
if (spaceBelow >= estimatedCardHeight || spaceBelow > spaceAbove) {
|
|
style.top = `${rect.bottom + offset}px`
|
|
style.left = `${adjustedCenterX}px`
|
|
style.transform = 'translateX(-50%)'
|
|
} else {
|
|
style.bottom = `${window.innerHeight - rect.top + offset}px`
|
|
style.left = `${adjustedCenterX}px`
|
|
style.transform = 'translateX(-50%)'
|
|
}
|
|
} else if (position === 'top') {
|
|
const spaceAbove = rect.top - offset - padding
|
|
const spaceBelow = window.innerHeight - rect.bottom - offset - padding
|
|
|
|
if (spaceAbove >= estimatedCardHeight || spaceAbove > spaceBelow) {
|
|
style.bottom = `${window.innerHeight - rect.top + offset}px`
|
|
style.left = `${adjustedCenterX}px`
|
|
style.transform = 'translateX(-50%)'
|
|
} else {
|
|
style.top = `${rect.bottom + offset}px`
|
|
style.left = `${adjustedCenterX}px`
|
|
style.transform = 'translateX(-50%)'
|
|
}
|
|
} else if (position === 'right') {
|
|
const spaceRight = window.innerWidth - rect.right - offset - padding
|
|
const spaceLeft = rect.left - offset - padding
|
|
|
|
const centerY = rect.top + rect.height / 2
|
|
const adjustedCenterY = Math.max(
|
|
estimatedCardHeight / 2 + padding,
|
|
Math.min(centerY, window.innerHeight - estimatedCardHeight / 2 - padding),
|
|
)
|
|
|
|
if (spaceRight >= cardWidth || spaceRight > spaceLeft) {
|
|
style.left = `${rect.right + offset}px`
|
|
style.top = `${adjustedCenterY}px`
|
|
style.transform = 'translateY(-50%)'
|
|
} else {
|
|
style.right = `${window.innerWidth - rect.left + offset}px`
|
|
style.top = `${adjustedCenterY}px`
|
|
style.transform = 'translateY(-50%)'
|
|
}
|
|
} else if (position === 'left') {
|
|
const spaceLeft = rect.left - offset - padding
|
|
const spaceRight = window.innerWidth - rect.right - offset - padding
|
|
|
|
const centerY = rect.top + rect.height / 2
|
|
const adjustedCenterY = Math.max(
|
|
estimatedCardHeight / 2 + padding,
|
|
Math.min(centerY, window.innerHeight - estimatedCardHeight / 2 - padding),
|
|
)
|
|
|
|
if (spaceLeft >= cardWidth || spaceLeft > spaceRight) {
|
|
style.right = `${window.innerWidth - rect.left + offset}px`
|
|
style.top = `${adjustedCenterY}px`
|
|
style.transform = 'translateY(-50%)'
|
|
} else {
|
|
style.left = `${rect.right + offset}px`
|
|
style.top = `${adjustedCenterY}px`
|
|
style.transform = 'translateY(-50%)'
|
|
}
|
|
}
|
|
|
|
return style
|
|
})
|
|
|
|
watch(currentStep, () => {
|
|
updateSpotlight()
|
|
})
|
|
|
|
watch(
|
|
() => route.path,
|
|
() => {
|
|
if (isVisible.value) {
|
|
updateSpotlight()
|
|
}
|
|
},
|
|
)
|
|
|
|
const handleNext = async () => {
|
|
if (currentStep.value < steps.length - 1) {
|
|
currentStep.value++
|
|
|
|
if (currentStep.value === 4) {
|
|
await router.push('/main-page/settings')
|
|
await new Promise(resolve => setTimeout(resolve, 400))
|
|
await updateSpotlight()
|
|
}
|
|
}
|
|
}
|
|
|
|
const handlePrevious = () => {
|
|
if (currentStep.value > 0) {
|
|
currentStep.value--
|
|
}
|
|
}
|
|
|
|
const handleSkip = () => {
|
|
isVisible.value = false
|
|
hasSeenGuide.value = true
|
|
}
|
|
|
|
const handleClose = () => {
|
|
isVisible.value = false
|
|
hasSeenGuide.value = true
|
|
}
|
|
|
|
const handleFinish = () => {
|
|
isVisible.value = false
|
|
hasSeenGuide.value = true
|
|
}
|
|
|
|
const restartGuide = () => {
|
|
currentStep.value = 0
|
|
isVisible.value = true
|
|
}
|
|
|
|
defineExpose({
|
|
restartGuide,
|
|
})
|
|
|
|
onMounted(async () => {
|
|
if (!hasSeenGuide.value) {
|
|
setTimeout(() => {
|
|
isVisible.value = true
|
|
updateSpotlight()
|
|
}, 500)
|
|
}
|
|
|
|
window.addEventListener('resize', updateSpotlight)
|
|
})
|
|
</script>
|