初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+461
View File
@@ -0,0 +1,461 @@
<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 #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>