feat(mobile): add onboarding tour for voting page

- Add OnboardingTour component with step-by-step guidance
- Add useOnboarding composable for state management
- Reset onboarding on logout for re-viewing
- 4 steps: welcome, award selection, program voting, progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-03 23:09:02 +08:00
parent eee34916b0
commit 65b153e5df
4 changed files with 511 additions and 3 deletions

View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import type { TourStep } from '../composables/useOnboarding';
const props = defineProps<{
steps: TourStep[];
show: boolean;
currentStep: number;
}>();
const emit = defineEmits<{
next: [];
prev: [];
skip: [];
complete: [];
}>();
// Target element rect
const targetRect = ref<DOMRect | null>(null);
const tooltipRef = ref<HTMLElement | null>(null);
// Update target element position
function updateTargetRect() {
const step = props.steps[props.currentStep];
if (!step?.target) {
targetRect.value = null;
return;
}
const el = document.querySelector(step.target);
if (el) {
targetRect.value = el.getBoundingClientRect();
} else {
targetRect.value = null;
}
}
// Computed styles for highlight
const highlightStyle = computed(() => {
if (!targetRect.value) return {};
const padding = 8;
return {
top: `${targetRect.value.top - padding}px`,
left: `${targetRect.value.left - padding}px`,
width: `${targetRect.value.width + padding * 2}px`,
height: `${targetRect.value.height + padding * 2}px`,
};
});
// Tooltip position
const tooltipStyle = computed(() => {
const step = props.steps[props.currentStep];
if (!step) return {};
// Center position (no target)
if (!targetRect.value) {
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
}
const pos = step.position || 'bottom';
const rect = targetRect.value;
const gap = 16;
switch (pos) {
case 'top':
return {
bottom: `${window.innerHeight - rect.top + gap}px`,
left: `${rect.left + rect.width / 2}px`,
transform: 'translateX(-50%)',
};
case 'bottom':
return {
top: `${rect.bottom + gap}px`,
left: `${rect.left + rect.width / 2}px`,
transform: 'translateX(-50%)',
};
case 'left':
return {
top: `${rect.top + rect.height / 2}px`,
right: `${window.innerWidth - rect.left + gap}px`,
transform: 'translateY(-50%)',
};
case 'right':
return {
top: `${rect.top + rect.height / 2}px`,
left: `${rect.right + gap}px`,
transform: 'translateY(-50%)',
};
default:
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
}
});
// Arrow direction (opposite of tooltip position)
const arrowDirection = computed(() => {
const step = props.steps[props.currentStep];
if (!step?.target || !targetRect.value) return '';
return step.position || 'bottom';
});
// Current step data
const currentStepData = computed(() => props.steps[props.currentStep]);
const isLastStep = computed(() => props.currentStep === props.steps.length - 1);
const isFirstStep = computed(() => props.currentStep === 0);
// Button text
const nextButtonText = computed(() => {
if (isFirstStep.value) return '开始了解';
if (isLastStep.value) return '开始投票';
return '下一步';
});
function handleNext() {
if (isLastStep.value) {
emit('complete');
} else {
emit('next');
}
}
// Watch for step changes
watch(() => props.currentStep, () => {
nextTick(updateTargetRect);
});
watch(() => props.show, (val) => {
if (val) {
nextTick(updateTargetRect);
}
});
// Handle resize
onMounted(() => {
window.addEventListener('resize', updateTargetRect);
window.addEventListener('scroll', updateTargetRect, true);
if (props.show) {
nextTick(updateTargetRect);
}
});
onUnmounted(() => {
window.removeEventListener('resize', updateTargetRect);
window.removeEventListener('scroll', updateTargetRect, true);
});
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="show" class="onboarding-overlay">
<!-- Backdrop with hole -->
<div class="backdrop" :class="{ 'has-target': !!targetRect }">
<svg v-if="targetRect" class="backdrop-svg" width="100%" height="100%">
<defs>
<mask id="hole-mask">
<rect width="100%" height="100%" fill="white" />
<rect
:x="targetRect.left - 8"
:y="targetRect.top - 8"
:width="targetRect.width + 16"
:height="targetRect.height + 16"
rx="8"
fill="black"
/>
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.75)" mask="url(#hole-mask)" />
</svg>
<div v-else class="backdrop-solid" />
</div>
<!-- Highlight border -->
<div v-if="targetRect" class="highlight-border" :style="highlightStyle" />
<!-- Tooltip -->
<div ref="tooltipRef" class="tooltip" :style="tooltipStyle">
<!-- Arrow -->
<div v-if="arrowDirection" class="tooltip-arrow" :class="`arrow-${arrowDirection}`" />
<!-- Content -->
<div class="tooltip-content">
<h3 v-if="currentStepData?.title" class="tooltip-title">
{{ currentStepData.title }}
</h3>
<p class="tooltip-text">{{ currentStepData?.content }}</p>
</div>
<!-- Footer -->
<div class="tooltip-footer">
<div class="step-indicator">
<span
v-for="(_, idx) in steps"
:key="idx"
class="step-dot"
:class="{ active: idx === currentStep, completed: idx < currentStep }"
/>
</div>
<div class="tooltip-actions">
<button class="btn-skip" @click="$emit('skip')">跳过</button>
<button class="btn-next" @click="handleNext">
{{ nextButtonText }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.onboarding-overlay {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: auto;
}
.backdrop {
position: absolute;
inset: 0;
}
.backdrop-svg {
position: absolute;
inset: 0;
}
.backdrop-solid {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
}
.highlight-border {
position: fixed;
border: 2px solid $color-gold;
border-radius: 8px;
box-shadow: 0 0 0 4px rgba($color-gold, 0.3);
pointer-events: none;
transition: all 0.3s ease;
}
.tooltip {
position: fixed;
max-width: 300px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 2px solid $color-gold;
z-index: 10000;
overflow: hidden;
}
.tooltip-arrow {
position: absolute;
width: 12px;
height: 12px;
background: #fff;
border: 2px solid $color-gold;
transform: rotate(45deg);
&.arrow-top {
bottom: -8px;
left: 50%;
margin-left: -6px;
border-top: none;
border-left: none;
}
&.arrow-bottom {
top: -8px;
left: 50%;
margin-left: -6px;
border-bottom: none;
border-right: none;
}
&.arrow-left {
right: -8px;
top: 50%;
margin-top: -6px;
border-top: none;
border-left: none;
}
&.arrow-right {
left: -8px;
top: 50%;
margin-top: -6px;
border-bottom: none;
border-right: none;
}
}
.tooltip-content {
padding: 16px 16px 12px;
}
.tooltip-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: $color-primary;
}
.tooltip-text {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: $color-text-primary;
}
.tooltip-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
border-top: 1px solid #eee;
}
.step-indicator {
display: flex;
gap: 6px;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ddd;
transition: all 0.2s ease;
&.active {
background: $color-gold;
transform: scale(1.2);
}
&.completed {
background: $color-primary;
}
}
.tooltip-actions {
display: flex;
align-items: center;
gap: 12px;
}
.btn-skip {
background: none;
border: none;
color: $color-text-muted;
font-size: 14px;
cursor: pointer;
padding: 4px 8px;
&:active {
color: $color-text-secondary;
}
}
.btn-next {
background: linear-gradient(135deg, $color-gold 0%, $color-gold-dark 100%);
border: none;
color: #1a1a1a;
font-size: 14px;
font-weight: 600;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 8px rgba($color-gold, 0.4);
&:active {
transform: scale(0.96);
}
}
// Transitions
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -31,7 +31,7 @@ function handleFrameClick(awardId: string) {
</script>
<template>
<div class="voting-dock">
<div class="voting-dock" data-tour="voting-dock">
<!-- 选中提示 -->
<div v-if="votingStore.selectedAward" class="selection-hint">
<span class="hint-icon">{{ votingStore.selectedAward.icon }}</span>

View File

@@ -0,0 +1,64 @@
import { ref, computed } from 'vue';
export interface TourStep {
target?: string;
title?: string;
content: string;
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
}
const STORAGE_KEY = 'gala_onboarding_completed';
// Shared state across components
const isCompleted = ref(localStorage.getItem(STORAGE_KEY) === 'true');
const showTour = ref(false);
const currentStep = ref(0);
export function useOnboarding() {
const shouldShowTour = computed(() => !isCompleted.value);
function start() {
if (isCompleted.value) return;
currentStep.value = 0;
showTour.value = true;
}
function next() {
currentStep.value++;
}
function prev() {
if (currentStep.value > 0) {
currentStep.value--;
}
}
function skip() {
complete();
}
function complete() {
showTour.value = false;
isCompleted.value = true;
localStorage.setItem(STORAGE_KEY, 'true');
}
function reset() {
localStorage.removeItem(STORAGE_KEY);
isCompleted.value = false;
currentStep.value = 0;
}
return {
isCompleted,
showTour,
currentStep,
shouldShowTour,
start,
next,
prev,
skip,
complete,
reset,
};
}

View File

@@ -1,11 +1,38 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { computed, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useVotingStore } from '../stores/voting';
import { useConnectionStore } from '../stores/connection';
import { showConfirmDialog } from 'vant';
import VotingDock from '../components/VotingDock.vue';
import ProgramCard from '../components/ProgramCard.vue';
import OnboardingTour from '../components/OnboardingTour.vue';
import { useOnboarding } from '../composables/useOnboarding';
// Onboarding
const { showTour, currentStep, shouldShowTour, start: startTour, next, skip, complete, reset: resetOnboarding } = useOnboarding();
const tourSteps = [
{
content: '欢迎参与节目投票!您有 7 张选票,可以为喜欢的节目投出不同奖项。',
position: 'center' as const,
},
{
target: '[data-tour="voting-dock"]',
content: '第一步:选择一个奖项。每个奖项只能投给一个节目。',
position: 'top' as const,
},
{
target: '[data-tour="program-card"]',
content: '第二步:点击节目卡片,将选中的奖项投给它。',
position: 'bottom' as const,
},
{
target: '[data-tour="progress-ring"]',
content: '这里显示您的投票进度,投完所有票后可查看结果。',
position: 'bottom' as const,
},
];
const router = useRouter();
const votingStore = useVotingStore();
@@ -43,6 +70,7 @@ async function handleLogout() {
confirmButtonText: '确定',
cancelButtonText: '取消',
});
resetOnboarding(); // 重置引导状态,下次登录可再次查看
connectionStore.logout();
router.replace('/');
} catch {
@@ -54,6 +82,12 @@ onMounted(() => {
if (!connectionStore.isConnected) {
connectionStore.connect();
}
// Start onboarding tour if not completed
if (shouldShowTour.value) {
nextTick(() => {
setTimeout(startTour, 500);
});
}
});
</script>
@@ -74,7 +108,7 @@ onMounted(() => {
{{ votingStatusMessage }}
</span>
<!-- 右侧进度环 -->
<div class="progress-ring">
<div class="progress-ring" data-tour="progress-ring">
<svg viewBox="0 0 36 36" class="circular-progress">
<path class="circle-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="circle-progress" :stroke-dasharray="`${votingStore.totalTicketCount > 0 ? (votingStore.usedTicketCount / votingStore.totalTicketCount) * 100 : 0}, 100`"
@@ -98,11 +132,22 @@ onMounted(() => {
:index="index"
:status="program.status"
:is-current="program.id === votingStore.currentProgramId"
:data-tour="index === 0 ? 'program-card' : undefined"
/>
</main>
<!-- Voting Dock -->
<VotingDock />
<!-- Onboarding Tour -->
<OnboardingTour
:steps="tourSteps"
:show="showTour"
:current-step="currentStep"
@next="next"
@skip="skip"
@complete="complete"
/>
</div>
</template>