Files
BYW-VoiceHub/app/components/UserTutorial.vue
T
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

462 lines
12 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-text-primary flex items-center gap-2">
<BookOpen class="w-5 h-5 text-primary" />
使用指南
</h3>
<p class="text-xs text-text-secondary mt-1">快速了解如何使用宝一外校园点歌系统</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-primary/10 text-primary">
<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-success/10 text-success">
<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-[#a855f7]/10 text-[#a855f7]">
<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: '欢迎来到宝一外校园点歌系统!',
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: var(--bg-secondary, rgba(0, 0, 0, 0.4));
border-radius: 16px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
backdrop-filter: blur(10px);
}
.tutorial-card {
background: var(--bg-tertiary, rgba(255, 255, 255, 0.02));
border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
}
.tutorial-card:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
border-color: var(--border-tertiary, 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: var(--text-primary, rgba(255, 255, 255, 0.9));
margin-bottom: 0.5rem;
}
.card-desc {
font-family: 'MiSans', sans-serif;
font-size: 0.8rem;
color: var(--text-secondary, 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: var(--text-primary);
margin: 0;
}
.tour-close-btn {
background: none;
border: none;
color: var(--text-tertiary);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
margin: -4px -4px 0 0;
}
.tour-close-btn:hover {
color: var(--text-primary);
}
.tour-body p {
color: var(--text-secondary);
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: var(--text-tertiary);
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: var(--text-primary);
border: 1px solid var(--border-secondary, #3f3f46);
}
.tour-btn-secondary:hover {
background: #3f3f46;
}
.tour-btn-primary {
background: #3b82f6;
color: var(--btn-primary-text);
border: none;
}
.tour-btn-primary:hover {
background: var(--bg-tertiary, #2563eb);
}
</style>