462 lines
11 KiB
Vue
462 lines
11 KiB
Vue
<template>
|
||
<div class="user-tutorial-container relative">
|
||
<div class="tutorial-header flex justify-between items-start">
|
||
<div>
|
||
<h3 class="text-lg font-black text-zinc-100 flex items-center gap-2">
|
||
<BookOpen class="w-5 h-5 text-blue-400" />
|
||
使用指南
|
||
</h3>
|
||
<p class="text-xs text-zinc-500 mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
|
||
</div>
|
||
<button
|
||
class="start-tour-btn"
|
||
@click="startTour"
|
||
>
|
||
<Play class="w-4 h-4" />
|
||
开始交互式教程
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||
<div class="tutorial-card">
|
||
<div class="card-icon bg-blue-500/10 text-blue-400">
|
||
<Search class="w-6 h-6" />
|
||
</div>
|
||
<h4 class="card-title">1. 搜索与投稿</h4>
|
||
<p class="card-desc">在“投稿歌曲”页面搜索您想听的音乐,选择合适的期望播出日期和序号,填写寄语后提交。</p>
|
||
</div>
|
||
|
||
<div class="tutorial-card">
|
||
<div class="card-icon bg-emerald-500/10 text-emerald-400">
|
||
<ThumbsUp class="w-6 h-6" />
|
||
</div>
|
||
<h4 class="card-title">2. 互动与点赞</h4>
|
||
<p class="card-desc">在“歌曲列表”中浏览大家提交的歌曲,为您喜欢的歌曲点赞,提高被选中的概率。</p>
|
||
</div>
|
||
|
||
<div class="tutorial-card">
|
||
<div class="card-icon bg-purple-500/10 text-purple-400">
|
||
<CalendarDays class="w-6 h-6" />
|
||
</div>
|
||
<h4 class="card-title">3. 关注排期与通知</h4>
|
||
<p class="card-desc">在“播出排期”中查看即将播出的节目单。您的歌曲被选中或播放时,会在“消息”中收到通知。</p>
|
||
</div>
|
||
</div>
|
||
<Teleport to="body" v-if="isTourActive">
|
||
<div class="tour-overlay"></div>
|
||
<div
|
||
class="tour-highlight-box"
|
||
:style="highlightStyle"
|
||
></div>
|
||
<div
|
||
class="tour-popover"
|
||
:style="popoverStyle"
|
||
>
|
||
<div class="tour-header">
|
||
<h4 class="tour-title">{{ currentStepData.title }}</h4>
|
||
<button class="tour-close-btn" @click="closeTour">×</button>
|
||
</div>
|
||
<div class="tour-body">
|
||
<p>{{ currentStepData.description }}</p>
|
||
</div>
|
||
<div class="tour-footer">
|
||
<span class="tour-progress">{{ currentStepIndex + 1 }} / {{ tourSteps.length }}</span>
|
||
<div class="tour-actions">
|
||
<button
|
||
v-if="currentStepIndex > 0"
|
||
class="tour-btn tour-btn-secondary"
|
||
@click="prevStep"
|
||
>上一步</button>
|
||
<button
|
||
class="tour-btn tour-btn-primary"
|
||
@click="nextStep"
|
||
>
|
||
{{ currentStepIndex === tourSteps.length - 1 ? '完成' : '下一步' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, ref, computed, nextTick } from 'vue'
|
||
import { BookOpen, Search, ThumbsUp, CalendarDays, Play } from '@lucide/vue'
|
||
|
||
const isTourActive = ref(false)
|
||
const currentStepIndex = ref(0)
|
||
const highlightRect = ref({ top: 0, left: 0, width: 0, height: 0 })
|
||
const windowSize = ref({ width: 0, height: 0 })
|
||
|
||
const tourSteps = [
|
||
{
|
||
element: '.user-tutorial-container',
|
||
title: '欢迎来到 VoiceHub!',
|
||
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
|
||
position: 'top'
|
||
},
|
||
{
|
||
element: '.songs-tab',
|
||
title: '歌曲列表',
|
||
description: '在这里,您可以查看大家投稿的歌曲,并为喜欢的歌曲点赞。点赞数越高的歌曲,越容易被选中哦!',
|
||
position: 'bottom'
|
||
},
|
||
{
|
||
element: '.request-tab',
|
||
title: '投稿歌曲',
|
||
description: '想要点歌?点击这里搜索您想听的音乐,选择期望播出的日期和序号,填写您的寄语即可提交。',
|
||
position: 'bottom'
|
||
},
|
||
{
|
||
element: '.schedule-tab',
|
||
title: '播出排期',
|
||
description: '想知道您的歌曲什么时候播出?或者看看今天的节目单?都在这里。',
|
||
position: 'bottom'
|
||
}
|
||
]
|
||
|
||
const currentStepData = computed(() => tourSteps[currentStepIndex.value])
|
||
const isMobile = computed(() => windowSize.value.width > 0 && windowSize.value.width <= 640)
|
||
|
||
const setBodyScrollLocked = (locked) => {
|
||
if (!import.meta.client) return
|
||
document.body.style.overflow = locked ? 'hidden' : ''
|
||
}
|
||
|
||
const updateHighlightRect = () => {
|
||
if (!import.meta.client) return
|
||
|
||
const el = document.querySelector(currentStepData.value.element)
|
||
if (el) {
|
||
if (typeof el.scrollIntoView === 'function') {
|
||
try {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
} catch {
|
||
window.scrollTo({
|
||
top: Math.max(0, window.scrollY + el.getBoundingClientRect().top - window.innerHeight / 2),
|
||
behavior: 'smooth'
|
||
})
|
||
}
|
||
}
|
||
|
||
const rect = el.getBoundingClientRect()
|
||
highlightRect.value = {
|
||
top: rect.top + window.scrollY,
|
||
left: rect.left + window.scrollX,
|
||
width: rect.width,
|
||
height: rect.height
|
||
}
|
||
|
||
setTimeout(() => {
|
||
const rect2 = el.getBoundingClientRect()
|
||
highlightRect.value = {
|
||
top: rect2.top + window.scrollY,
|
||
left: rect2.left + window.scrollX,
|
||
width: rect2.width,
|
||
height: rect2.height
|
||
}
|
||
}, 320)
|
||
}
|
||
}
|
||
|
||
const highlightStyle = computed(() => ({
|
||
top: `${highlightRect.value.top - 8}px`,
|
||
left: `${highlightRect.value.left - 8}px`,
|
||
width: `${highlightRect.value.width + 16}px`,
|
||
height: `${highlightRect.value.height + 16}px`,
|
||
}))
|
||
|
||
const popoverStyle = computed(() => {
|
||
if (isMobile.value) {
|
||
return {
|
||
top: 'calc(env(safe-area-inset-top, 0px) + 12px)',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
position: 'fixed',
|
||
bottom: 'auto'
|
||
}
|
||
}
|
||
|
||
const rect = highlightRect.value
|
||
const pos = currentStepData.value.position
|
||
const spacing = 16
|
||
|
||
// 默认居中显示(如果没找到元素)
|
||
if (rect.width === 0) {
|
||
return {
|
||
top: '50%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
position: 'fixed'
|
||
}
|
||
}
|
||
|
||
let top = 0
|
||
let left = 0
|
||
|
||
if (pos === 'bottom') {
|
||
top = rect.top + rect.height + spacing
|
||
left = rect.left + rect.width / 2
|
||
return {
|
||
top: `${top}px`,
|
||
left: `${left}px`,
|
||
transform: 'translateX(-50%)',
|
||
position: 'absolute'
|
||
}
|
||
} else {
|
||
// top position
|
||
top = rect.top - spacing
|
||
left = rect.left + rect.width / 2
|
||
return {
|
||
top: `${top}px`,
|
||
left: `${left}px`,
|
||
transform: 'translate(-50%, -100%)',
|
||
position: 'absolute'
|
||
}
|
||
}
|
||
})
|
||
|
||
const startTour = async () => {
|
||
if (!import.meta.client) return
|
||
isTourActive.value = true
|
||
currentStepIndex.value = 0
|
||
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
|
||
window.addEventListener('resize', handleResize)
|
||
setBodyScrollLocked(true)
|
||
|
||
await nextTick()
|
||
updateHighlightRect()
|
||
}
|
||
|
||
const closeTour = () => {
|
||
isTourActive.value = false
|
||
if (import.meta.client) {
|
||
window.removeEventListener('resize', handleResize)
|
||
}
|
||
setBodyScrollLocked(false)
|
||
}
|
||
|
||
const nextStep = async () => {
|
||
if (currentStepIndex.value < tourSteps.length - 1) {
|
||
currentStepIndex.value++
|
||
await nextTick()
|
||
updateHighlightRect()
|
||
} else {
|
||
closeTour()
|
||
}
|
||
}
|
||
|
||
const prevStep = async () => {
|
||
if (currentStepIndex.value > 0) {
|
||
currentStepIndex.value--
|
||
await nextTick()
|
||
updateHighlightRect()
|
||
}
|
||
}
|
||
|
||
const handleResize = () => {
|
||
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
|
||
updateHighlightRect()
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (import.meta.client) {
|
||
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
|
||
const hasSeenTour = localStorage.getItem('voicehub_has_seen_tour_v2')
|
||
if (!hasSeenTour) {
|
||
setTimeout(() => {
|
||
startTour()
|
||
localStorage.setItem('voicehub_has_seen_tour_v2', 'true')
|
||
}, 1500)
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.user-tutorial-container {
|
||
background: rgba(0, 0, 0, 0.4);
|
||
border-radius: 16px;
|
||
padding: 1.5rem;
|
||
margin-top: 1.5rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.tutorial-card {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
border-radius: 12px;
|
||
padding: 1.25rem;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.tutorial-card:hover {
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.card-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.card-title {
|
||
font-family: 'MiSans', sans-serif;
|
||
font-weight: 800;
|
||
font-size: 0.95rem;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.card-desc {
|
||
font-family: 'MiSans', sans-serif;
|
||
font-size: 0.8rem;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.start-tour-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
background: rgba(59, 130, 246, 0.15);
|
||
color: #60a5fa;
|
||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||
border-radius: 8px;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.start-tour-btn:hover {
|
||
background: rgba(59, 130, 246, 0.25);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
/* 简易教程引导样式 */
|
||
.tour-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: rgba(0, 0, 0, 0.75);
|
||
z-index: 9998;
|
||
}
|
||
|
||
.tour-highlight-box {
|
||
position: absolute;
|
||
z-index: 9999;
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 0, 0, 0.5);
|
||
border: 2px solid #3b82f6;
|
||
pointer-events: none;
|
||
transition: all 0.4s ease;
|
||
background: transparent;
|
||
}
|
||
|
||
.tour-popover {
|
||
background: #18181b;
|
||
border: 1px solid #27272a;
|
||
border-radius: 12px;
|
||
padding: 1.25rem;
|
||
width: 320px;
|
||
max-width: calc(100vw - 32px);
|
||
z-index: 10000;
|
||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
|
||
transition: all 0.4s ease;
|
||
}
|
||
|
||
.tour-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.tour-title {
|
||
font-size: 1.1rem;
|
||
font-weight: 800;
|
||
color: #f4f4f5;
|
||
margin: 0;
|
||
}
|
||
|
||
.tour-close-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #71717a;
|
||
font-size: 1.5rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
margin: -4px -4px 0 0;
|
||
}
|
||
|
||
.tour-close-btn:hover {
|
||
color: #f4f4f5;
|
||
}
|
||
|
||
.tour-body p {
|
||
color: #a1a1aa;
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
margin: 0 0 1rem 0;
|
||
}
|
||
|
||
.tour-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.tour-progress {
|
||
color: #71717a;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tour-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.tour-btn {
|
||
padding: 0.4rem 0.8rem;
|
||
border-radius: 6px;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.tour-btn-secondary {
|
||
background: #27272a;
|
||
color: #d4d4d8;
|
||
border: 1px solid #3f3f46;
|
||
}
|
||
|
||
.tour-btn-secondary:hover {
|
||
background: #3f3f46;
|
||
}
|
||
|
||
.tour-btn-primary {
|
||
background: #3b82f6;
|
||
color: white;
|
||
border: none;
|
||
}
|
||
|
||
.tour-btn-primary:hover {
|
||
background: #2563eb;
|
||
}
|
||
</style>
|