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>
<template>
<div class="video-background">
<!-- Loading 状态 -->
<div v-if="isLoading" class="video-loading">
<van-loading size="24px" color="#fff">加载中...</van-loading>
</div>
<!-- 视频元素 -->
<video
ref="videoRef"
class="video-element"
:src="videoSrc"
:poster="posterUrl"
:autoplay="autoplay"
:loop="loop"
:muted="muted"
:webkit-playsinline="true"
:playsinline="true"
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
@canplay="onCanPlay"
@error="onError"
></video>
<!-- 降级背景图 -->
<div
v-if="showFallback"
class="video-fallback"
:style="{ backgroundImage: `url(${posterUrl})` }"
></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
/** 视频源 URL */
videoSrc: {
type: String,
required: true
},
/** 封面图 URL (可选,不传则自动从视频生成) */
poster: {
type: String,
default: ''
},
/** 是否自动播放 */
autoplay: {
type: Boolean,
default: true
},
/** 是否循环播放 */
loop: {
type: Boolean,
default: true
},
/** 是否静音 */
muted: {
type: Boolean,
default: true
},
/** 视频填充模式 */
objectFit: {
type: String,
default: 'cover' // cover, contain, fill
}
})
const videoRef = ref(null)
const isLoading = ref(true)
const showFallback = ref(false)
/**
* 自动生成封面图 URL
* 如果没有传入 poster,使用七牛云视频处理参数从视频中提取第一帧
* 处理参数: ?vframe/jpg/offset/0.001 表示从视频第 0.001 秒截取一帧作为 JPG
*/
const posterUrl = computed(() => {
if (props.poster) {
return props.poster
}
// 从视频 URL 自动生成封面图
// 七牛云视频处理: https://developer.qiniu.com/dora/1316/video-frame-operation
return `${props.videoSrc}?vframe/jpg/offset/0.001`
})
// 视频可以播放时
const onCanPlay = () => {
isLoading.value = false
// 尝试自动播放
if (props.autoplay && videoRef.value) {
videoRef.value.play().catch(err => {
console.warn('[VideoBackground] 自动播放失败:', err)
// iOS Safari 可能需要用户交互才能播放
showFallback.value = true
})
}
}
// 视频加载错误
const onError = (e) => {
console.error('[VideoBackground] 视频加载失败:', e)
isLoading.value = false
showFallback.value = true
}
// 手动播放(用于处理需要用户交互的情况)
const play = () => {
if (videoRef.value) {
videoRef.value.play().catch(err => {
console.warn('[VideoBackground] 播放失败:', err)
})
}
}
// 暂停播放
const pause = () => {
if (videoRef.value) {
videoRef.value.pause()
}
}
onMounted(() => {
// 预加载视频
if (videoRef.value) {
videoRef.value.load()
}
})
onUnmounted(() => {
// 清理资源
if (videoRef.value) {
videoRef.value.pause()
videoRef.value.src = ''
}
})
defineExpose({
play,
pause
})
</script>
<style scoped>
.video-background {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
overflow: hidden;
}
.video-element {
width: 100%;
height: 100%;
object-fit: v-bind(objectFit);
background-color: #000;
}
.video-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
.video-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: -1;
}
</style>
......@@ -61,4 +61,22 @@ app.config.globalProperties.$http = axios; // 关键语句
// 在安装路由前进行一次 hash 复原,确保初始路由正确
// restoreHashAfterOAuth()
app.use(router)
// 开发环境添加欢迎页调试工具
if (import.meta.env.DEV) {
window.resetWelcomeFlag = () => {
const { resetWelcomeFlag } = require('./router/guards.js')
resetWelcomeFlag()
console.log('✅ 欢迎页标志已重置,请刷新页面查看欢迎页')
}
window.showWelcome = () => {
window.location.href = '/welcome'
}
console.log('🔧 开发工具:')
console.log(' - window.resetWelcomeFlag() 重置欢迎页标志')
console.log(' - window.showWelcome() 跳转到欢迎页')
}
app.mount('#app')
......
......@@ -92,6 +92,36 @@ export const startWxAuth = async () => {
location.href = short_url;
}
// 首次访问标志
const HAS_VISITED_WELCOME = 'has_visited_welcome'
const WELCOME_VISITED_AT = 'welcome_visited_at'
/**
* @description 检查用户是否已访问过欢迎页
* @returns {boolean}
*/
export const hasVisitedWelcome = () => {
return localStorage.getItem(HAS_VISITED_WELCOME) === 'true'
}
/**
* @description 标记用户已访问欢迎页
* @returns {void}
*/
export const markWelcomeVisited = () => {
localStorage.setItem(HAS_VISITED_WELCOME, 'true')
localStorage.setItem(WELCOME_VISITED_AT, Date.now().toString())
}
/**
* @description 重置欢迎页标志(用于调试)
* @returns {void}
*/
export const resetWelcomeFlag = () => {
localStorage.removeItem(HAS_VISITED_WELCOME)
localStorage.removeItem(WELCOME_VISITED_AT)
}
// 检查用户是否已登录
......
......@@ -7,7 +7,7 @@
*/
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from './routes'
import { checkAuth } from './guards'
import { checkAuth, hasVisitedWelcome, markWelcomeVisited } from './guards'
import { getUserIsLoginAPI } from '@/api/auth'
import { getUserInfoAPI } from '@/api/users'
......@@ -25,9 +25,31 @@ const router = createRouter({
// 导航守卫
router.beforeEach(async (to, from, next) => {
// 检查用户是否已登录
const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null')
const redirectRaw = to.query && to.query.redirect
// 欢迎页首次访问检测
if (import.meta.env.VITE_WELCOME_PAGE_ENABLED === 'true') {
// 重置欢迎页标志(URL 参数)
if (to.query.reset_welcome === 'true') {
const { resetWelcomeFlag } = await import('./guards.js')
resetWelcomeFlag()
// 移除 URL 参数
const query = { ...to.query }
delete query.reset_welcome
return next({ path: to.path, query })
}
// 首次访问检测
if (to.path !== '/welcome' && !hasVisitedWelcome()) {
markWelcomeVisited()
return next({
path: '/welcome',
query: { redirect: to.fullPath }
})
}
}
// 检查用户是否已登录
const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null')
const redirectRaw = to.query && to.query.redirect
// 登录权限检查(不再自动触发微信授权)
const authResult = checkAuth(to)
......
......@@ -10,6 +10,16 @@ import teacherRoutes from './teacher'
export const routes = [
{
path: '/welcome',
name: 'Welcome',
component: () => import('../views/welcome/WelcomePage.vue'),
meta: {
title: '欢迎页',
requiresAuth: false,
hideInMenu: true
}
},
{
path: '/',
name: 'HomePage',
component: () => import('../views/HomePage.vue'),
......
<template>
<div class="welcome-page">
<!-- 视频背景 -->
<VideoBackground
v-if="videoUrl"
:video-src="videoUrl"
/>
<!-- 内容区域 -->
<WelcomeContent class="welcome-content" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import VideoBackground from '@/components/effects/VideoBackground.vue'
import WelcomeContent from '@/components/welcome/WelcomeContent.vue'
const videoUrl = computed(() => {
return import.meta.env.VITE_WELCOME_VIDEO_URL || ''
})
// poster 会自动从 videoUrl 生成,无需单独配置
</script>
<style scoped>
.welcome-page {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
}
.welcome-content {
position: relative;
z-index: 1;
}
</style>