Files
BYW-VoiceHub/app/components/UserTutorial.vue
T
Henry_Du 9fc5e3e607
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Canceled after 1m27s
fix(ui): 批量替换所有组件中残留的硬编码深色值以彻底支持浅色主题
2026-09-12 15:56:10 +08:00

462 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&times;</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 var(--border-secondary, #3b82f6);
pointer-events: none;
transition: all 0.4s ease;
background: transparent;
}
.tour-popover {
background: var(--bg-secondary, #18181b);
border: 1px solid var(--border-secondary, #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: var(--bg-tertiary, #27272a);
color: #d4d4d8;
border: 1px solid var(--border-secondary, #3f3f46);
}
.tour-btn-secondary:hover {
background: #3f3f46;
}
.tour-btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.tour-btn-primary:hover {
background: var(--bg-tertiary, #2563eb);
}
</style>