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:
399
packages/client-mobile/src/components/OnboardingTour.vue
Normal file
399
packages/client-mobile/src/components/OnboardingTour.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
64
packages/client-mobile/src/composables/useOnboarding.ts
Normal file
64
packages/client-mobile/src/composables/useOnboarding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user