feat(welcome): 实现欢迎页核心功能 - 视频背景、路由与首次访问检测
第2-3阶段: 核心功能实现 新增组件: - VideoBackground.vue (视频背景组件) * 支持自动播放、循环播放、静音 * 自动从视频URL生成封面图 (七牛云 ?vframe/jpg/offset/0.001) * 完善的降级方案 (视频加载失败时使用静态背景图) * 支持 play/pause 方法暴露 * 移动端兼容 (playsinline, x5-video-player-type) - WelcomePage.vue (欢迎页视图) * 集成 VideoBackground 组件 * 从环境变量读取视频URL * 预留内容区域插槽 路由与守卫: - 路由配置: 添加 /welcome 路由 (routes.js) - 首次访问检测: 检查 localStorage 标志位 (index.js) - 标志位管理: hasVisitedWelcome(), markWelcomeVisited(), resetWelcomeFlag() - 调试工具: window.resetWelcomeFlag(), window.showWelcome() - URL参数: ?reset_welcome=true 重置标志 首次访问流程: 1. 用户首次访问 → 检测 localStorage 标志 2. 标志不存在 → 设置标志并跳转 /welcome 3. /welcome 页面显示视频背景和功能入口 4. 后续访问 → 直接进入目标页面 技术亮点: - 七牛云视频处理参数自动生成封面图 - 移动端全屏视频优化 - 环境变量开关 (VITE_WELCOME_PAGE_ENABLED) - 开发调试工具支持 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
6 changed files
with
304 additions
and
4 deletions
src/components/effects/VideoBackground.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="video-background"> | ||
| 3 | + <!-- Loading 状态 --> | ||
| 4 | + <div v-if="isLoading" class="video-loading"> | ||
| 5 | + <van-loading size="24px" color="#fff">加载中...</van-loading> | ||
| 6 | + </div> | ||
| 7 | + | ||
| 8 | + <!-- 视频元素 --> | ||
| 9 | + <video | ||
| 10 | + ref="videoRef" | ||
| 11 | + class="video-element" | ||
| 12 | + :src="videoSrc" | ||
| 13 | + :poster="posterUrl" | ||
| 14 | + :autoplay="autoplay" | ||
| 15 | + :loop="loop" | ||
| 16 | + :muted="muted" | ||
| 17 | + :webkit-playsinline="true" | ||
| 18 | + :playsinline="true" | ||
| 19 | + x5-video-player-type="h5" | ||
| 20 | + x5-video-player-fullscreen="true" | ||
| 21 | + @canplay="onCanPlay" | ||
| 22 | + @error="onError" | ||
| 23 | + ></video> | ||
| 24 | + | ||
| 25 | + <!-- 降级背景图 --> | ||
| 26 | + <div | ||
| 27 | + v-if="showFallback" | ||
| 28 | + class="video-fallback" | ||
| 29 | + :style="{ backgroundImage: `url(${posterUrl})` }" | ||
| 30 | + ></div> | ||
| 31 | + </div> | ||
| 32 | +</template> | ||
| 33 | + | ||
| 34 | +<script setup> | ||
| 35 | +import { ref, computed, onMounted, onUnmounted } from 'vue' | ||
| 36 | + | ||
| 37 | +const props = defineProps({ | ||
| 38 | + /** 视频源 URL */ | ||
| 39 | + videoSrc: { | ||
| 40 | + type: String, | ||
| 41 | + required: true | ||
| 42 | + }, | ||
| 43 | + /** 封面图 URL (可选,不传则自动从视频生成) */ | ||
| 44 | + poster: { | ||
| 45 | + type: String, | ||
| 46 | + default: '' | ||
| 47 | + }, | ||
| 48 | + /** 是否自动播放 */ | ||
| 49 | + autoplay: { | ||
| 50 | + type: Boolean, | ||
| 51 | + default: true | ||
| 52 | + }, | ||
| 53 | + /** 是否循环播放 */ | ||
| 54 | + loop: { | ||
| 55 | + type: Boolean, | ||
| 56 | + default: true | ||
| 57 | + }, | ||
| 58 | + /** 是否静音 */ | ||
| 59 | + muted: { | ||
| 60 | + type: Boolean, | ||
| 61 | + default: true | ||
| 62 | + }, | ||
| 63 | + /** 视频填充模式 */ | ||
| 64 | + objectFit: { | ||
| 65 | + type: String, | ||
| 66 | + default: 'cover' // cover, contain, fill | ||
| 67 | + } | ||
| 68 | +}) | ||
| 69 | + | ||
| 70 | +const videoRef = ref(null) | ||
| 71 | +const isLoading = ref(true) | ||
| 72 | +const showFallback = ref(false) | ||
| 73 | + | ||
| 74 | +/** | ||
| 75 | + * 自动生成封面图 URL | ||
| 76 | + * 如果没有传入 poster,使用七牛云视频处理参数从视频中提取第一帧 | ||
| 77 | + * 处理参数: ?vframe/jpg/offset/0.001 表示从视频第 0.001 秒截取一帧作为 JPG | ||
| 78 | + */ | ||
| 79 | +const posterUrl = computed(() => { | ||
| 80 | + if (props.poster) { | ||
| 81 | + return props.poster | ||
| 82 | + } | ||
| 83 | + // 从视频 URL 自动生成封面图 | ||
| 84 | + // 七牛云视频处理: https://developer.qiniu.com/dora/1316/video-frame-operation | ||
| 85 | + return `${props.videoSrc}?vframe/jpg/offset/0.001` | ||
| 86 | +}) | ||
| 87 | + | ||
| 88 | +// 视频可以播放时 | ||
| 89 | +const onCanPlay = () => { | ||
| 90 | + isLoading.value = false | ||
| 91 | + | ||
| 92 | + // 尝试自动播放 | ||
| 93 | + if (props.autoplay && videoRef.value) { | ||
| 94 | + videoRef.value.play().catch(err => { | ||
| 95 | + console.warn('[VideoBackground] 自动播放失败:', err) | ||
| 96 | + // iOS Safari 可能需要用户交互才能播放 | ||
| 97 | + showFallback.value = true | ||
| 98 | + }) | ||
| 99 | + } | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +// 视频加载错误 | ||
| 103 | +const onError = (e) => { | ||
| 104 | + console.error('[VideoBackground] 视频加载失败:', e) | ||
| 105 | + isLoading.value = false | ||
| 106 | + showFallback.value = true | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// 手动播放(用于处理需要用户交互的情况) | ||
| 110 | +const play = () => { | ||
| 111 | + if (videoRef.value) { | ||
| 112 | + videoRef.value.play().catch(err => { | ||
| 113 | + console.warn('[VideoBackground] 播放失败:', err) | ||
| 114 | + }) | ||
| 115 | + } | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +// 暂停播放 | ||
| 119 | +const pause = () => { | ||
| 120 | + if (videoRef.value) { | ||
| 121 | + videoRef.value.pause() | ||
| 122 | + } | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +onMounted(() => { | ||
| 126 | + // 预加载视频 | ||
| 127 | + if (videoRef.value) { | ||
| 128 | + videoRef.value.load() | ||
| 129 | + } | ||
| 130 | +}) | ||
| 131 | + | ||
| 132 | +onUnmounted(() => { | ||
| 133 | + // 清理资源 | ||
| 134 | + if (videoRef.value) { | ||
| 135 | + videoRef.value.pause() | ||
| 136 | + videoRef.value.src = '' | ||
| 137 | + } | ||
| 138 | +}) | ||
| 139 | + | ||
| 140 | +defineExpose({ | ||
| 141 | + play, | ||
| 142 | + pause | ||
| 143 | +}) | ||
| 144 | +</script> | ||
| 145 | + | ||
| 146 | +<style scoped> | ||
| 147 | +.video-background { | ||
| 148 | + position: fixed; | ||
| 149 | + top: 0; | ||
| 150 | + left: 0; | ||
| 151 | + width: 100vw; | ||
| 152 | + height: 100vh; | ||
| 153 | + z-index: -1; | ||
| 154 | + overflow: hidden; | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +.video-element { | ||
| 158 | + width: 100%; | ||
| 159 | + height: 100%; | ||
| 160 | + object-fit: v-bind(objectFit); | ||
| 161 | + background-color: #000; | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +.video-loading { | ||
| 165 | + position: absolute; | ||
| 166 | + top: 50%; | ||
| 167 | + left: 50%; | ||
| 168 | + transform: translate(-50%, -50%); | ||
| 169 | + z-index: 1; | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +.video-fallback { | ||
| 173 | + position: absolute; | ||
| 174 | + top: 0; | ||
| 175 | + left: 0; | ||
| 176 | + width: 100%; | ||
| 177 | + height: 100%; | ||
| 178 | + background-size: cover; | ||
| 179 | + background-position: center; | ||
| 180 | + background-repeat: no-repeat; | ||
| 181 | + z-index: -1; | ||
| 182 | +} | ||
| 183 | +</style> |
| ... | @@ -61,4 +61,22 @@ app.config.globalProperties.$http = axios; // 关键语句 | ... | @@ -61,4 +61,22 @@ app.config.globalProperties.$http = axios; // 关键语句 |
| 61 | // 在安装路由前进行一次 hash 复原,确保初始路由正确 | 61 | // 在安装路由前进行一次 hash 复原,确保初始路由正确 |
| 62 | // restoreHashAfterOAuth() | 62 | // restoreHashAfterOAuth() |
| 63 | app.use(router) | 63 | app.use(router) |
| 64 | + | ||
| 65 | +// 开发环境添加欢迎页调试工具 | ||
| 66 | +if (import.meta.env.DEV) { | ||
| 67 | + window.resetWelcomeFlag = () => { | ||
| 68 | + const { resetWelcomeFlag } = require('./router/guards.js') | ||
| 69 | + resetWelcomeFlag() | ||
| 70 | + console.log('✅ 欢迎页标志已重置,请刷新页面查看欢迎页') | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + window.showWelcome = () => { | ||
| 74 | + window.location.href = '/welcome' | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + console.log('🔧 开发工具:') | ||
| 78 | + console.log(' - window.resetWelcomeFlag() 重置欢迎页标志') | ||
| 79 | + console.log(' - window.showWelcome() 跳转到欢迎页') | ||
| 80 | +} | ||
| 81 | + | ||
| 64 | app.mount('#app') | 82 | app.mount('#app') | ... | ... |
| ... | @@ -92,6 +92,36 @@ export const startWxAuth = async () => { | ... | @@ -92,6 +92,36 @@ export const startWxAuth = async () => { |
| 92 | location.href = short_url; | 92 | location.href = short_url; |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | +// 首次访问标志 | ||
| 96 | +const HAS_VISITED_WELCOME = 'has_visited_welcome' | ||
| 97 | +const WELCOME_VISITED_AT = 'welcome_visited_at' | ||
| 98 | + | ||
| 99 | +/** | ||
| 100 | + * @description 检查用户是否已访问过欢迎页 | ||
| 101 | + * @returns {boolean} | ||
| 102 | + */ | ||
| 103 | +export const hasVisitedWelcome = () => { | ||
| 104 | + return localStorage.getItem(HAS_VISITED_WELCOME) === 'true' | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +/** | ||
| 108 | + * @description 标记用户已访问欢迎页 | ||
| 109 | + * @returns {void} | ||
| 110 | + */ | ||
| 111 | +export const markWelcomeVisited = () => { | ||
| 112 | + localStorage.setItem(HAS_VISITED_WELCOME, 'true') | ||
| 113 | + localStorage.setItem(WELCOME_VISITED_AT, Date.now().toString()) | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +/** | ||
| 117 | + * @description 重置欢迎页标志(用于调试) | ||
| 118 | + * @returns {void} | ||
| 119 | + */ | ||
| 120 | +export const resetWelcomeFlag = () => { | ||
| 121 | + localStorage.removeItem(HAS_VISITED_WELCOME) | ||
| 122 | + localStorage.removeItem(WELCOME_VISITED_AT) | ||
| 123 | +} | ||
| 124 | + | ||
| 95 | // 检查用户是否已登录 | 125 | // 检查用户是否已登录 |
| 96 | 126 | ||
| 97 | 127 | ... | ... |
| ... | @@ -7,7 +7,7 @@ | ... | @@ -7,7 +7,7 @@ |
| 7 | */ | 7 | */ |
| 8 | import { createRouter, createWebHashHistory } from 'vue-router' | 8 | import { createRouter, createWebHashHistory } from 'vue-router' |
| 9 | import { routes } from './routes' | 9 | import { routes } from './routes' |
| 10 | -import { checkAuth } from './guards' | 10 | +import { checkAuth, hasVisitedWelcome, markWelcomeVisited } from './guards' |
| 11 | import { getUserIsLoginAPI } from '@/api/auth' | 11 | import { getUserIsLoginAPI } from '@/api/auth' |
| 12 | import { getUserInfoAPI } from '@/api/users' | 12 | import { getUserInfoAPI } from '@/api/users' |
| 13 | 13 | ||
| ... | @@ -25,9 +25,31 @@ const router = createRouter({ | ... | @@ -25,9 +25,31 @@ const router = createRouter({ |
| 25 | 25 | ||
| 26 | // 导航守卫 | 26 | // 导航守卫 |
| 27 | router.beforeEach(async (to, from, next) => { | 27 | router.beforeEach(async (to, from, next) => { |
| 28 | - // 检查用户是否已登录 | 28 | + // 欢迎页首次访问检测 |
| 29 | - const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') | 29 | + if (import.meta.env.VITE_WELCOME_PAGE_ENABLED === 'true') { |
| 30 | - const redirectRaw = to.query && to.query.redirect | 30 | + // 重置欢迎页标志(URL 参数) |
| 31 | + if (to.query.reset_welcome === 'true') { | ||
| 32 | + const { resetWelcomeFlag } = await import('./guards.js') | ||
| 33 | + resetWelcomeFlag() | ||
| 34 | + // 移除 URL 参数 | ||
| 35 | + const query = { ...to.query } | ||
| 36 | + delete query.reset_welcome | ||
| 37 | + return next({ path: to.path, query }) | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + // 首次访问检测 | ||
| 41 | + if (to.path !== '/welcome' && !hasVisitedWelcome()) { | ||
| 42 | + markWelcomeVisited() | ||
| 43 | + return next({ | ||
| 44 | + path: '/welcome', | ||
| 45 | + query: { redirect: to.fullPath } | ||
| 46 | + }) | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + // 检查用户是否已登录 | ||
| 51 | + const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') | ||
| 52 | + const redirectRaw = to.query && to.query.redirect | ||
| 31 | 53 | ||
| 32 | // 登录权限检查(不再自动触发微信授权) | 54 | // 登录权限检查(不再自动触发微信授权) |
| 33 | const authResult = checkAuth(to) | 55 | const authResult = checkAuth(to) | ... | ... |
| ... | @@ -10,6 +10,16 @@ import teacherRoutes from './teacher' | ... | @@ -10,6 +10,16 @@ import teacherRoutes from './teacher' |
| 10 | 10 | ||
| 11 | export const routes = [ | 11 | export const routes = [ |
| 12 | { | 12 | { |
| 13 | + path: '/welcome', | ||
| 14 | + name: 'Welcome', | ||
| 15 | + component: () => import('../views/welcome/WelcomePage.vue'), | ||
| 16 | + meta: { | ||
| 17 | + title: '欢迎页', | ||
| 18 | + requiresAuth: false, | ||
| 19 | + hideInMenu: true | ||
| 20 | + } | ||
| 21 | + }, | ||
| 22 | + { | ||
| 13 | path: '/', | 23 | path: '/', |
| 14 | name: 'HomePage', | 24 | name: 'HomePage', |
| 15 | component: () => import('../views/HomePage.vue'), | 25 | component: () => import('../views/HomePage.vue'), | ... | ... |
src/views/welcome/WelcomePage.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="welcome-page"> | ||
| 3 | + <!-- 视频背景 --> | ||
| 4 | + <VideoBackground | ||
| 5 | + v-if="videoUrl" | ||
| 6 | + :video-src="videoUrl" | ||
| 7 | + /> | ||
| 8 | + | ||
| 9 | + <!-- 内容区域 --> | ||
| 10 | + <WelcomeContent class="welcome-content" /> | ||
| 11 | + </div> | ||
| 12 | +</template> | ||
| 13 | + | ||
| 14 | +<script setup> | ||
| 15 | +import { computed } from 'vue' | ||
| 16 | +import VideoBackground from '@/components/effects/VideoBackground.vue' | ||
| 17 | +import WelcomeContent from '@/components/welcome/WelcomeContent.vue' | ||
| 18 | + | ||
| 19 | +const videoUrl = computed(() => { | ||
| 20 | + return import.meta.env.VITE_WELCOME_VIDEO_URL || '' | ||
| 21 | +}) | ||
| 22 | +// poster 会自动从 videoUrl 生成,无需单独配置 | ||
| 23 | +</script> | ||
| 24 | + | ||
| 25 | +<style scoped> | ||
| 26 | +.welcome-page { | ||
| 27 | + position: relative; | ||
| 28 | + width: 100vw; | ||
| 29 | + min-height: 100vh; | ||
| 30 | + overflow: hidden; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +.welcome-content { | ||
| 34 | + position: relative; | ||
| 35 | + z-index: 1; | ||
| 36 | +} | ||
| 37 | +</style> |
-
Please register or login to post a comment