hookehuyr

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>
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'),
......
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>