hookehuyr

feat: 添加欢迎页开发计划和通用七牛云上传工具

新增欢迎页功能的详细开发计划文档,包括头脑风暴、架构设计和实现步骤
创建通用七牛云上传工具脚本,支持单文件上传和批量上传
添加视频背景组件和欢迎页路由配置的基础框架
实现首次访问检测逻辑,使用localStorage记录用户访问状态
# 欢迎页功能 - 头脑风暴与设计探索
## 项目背景
为 mlaj 平台设计一个欢迎页,作为用户首次进入时的引导页面,展示核心功能入口。
**核心需求:**
- 视频背景循环播放(星空宇宙主题)
- 悬浮的功能入口图标 + 文字介绍
- 持续循环的缩放位移动效
- 首次进入检测,非首次直接跳转主页
---
## 需求探索过程
### 问题 1: 欢迎页的主要目标?
**选项:**
- 品牌展示 - 营造高端/科技感的品牌印象
- 功能引导 - 清晰展示核心功能入口
- 新手引导 - 教育用户如何使用平台
- 混合型 - 品牌展示 + 功能引导 ✅
**决策:** 选择**混合型**,既要有视觉冲击力又要提供实用功能入口。
---
### 问题 2: 视频背景的实现方式?
**选项:**
- 原生 `<video>` 标签
- 简化版组件(参考 StarryBackground)✅
- 页面内直接实现
**决策:** 选择**简化版组件**,理由:
- 组件化便于复用和维护
- 移除 Canvas 星星特效,保留视频播放逻辑
- 支持 Props 配置,灵活性高
---
### 问题 3: 如何判断用户是否首次进入?
**选项:**
- localStorage 标志 ✅
- 后端接口判断
- URL 参数控制
**决策:** 选择 **localStorage 标志**,理由:
- 简单高效,无需额外请求
- 符合前端存储特性
- 开发调试方便(可手动清除)
**权衡:**
- 缺点: 清除缓存后会再次显示
- 解决方案: 文档说明这是预期行为,未来可升级为后端接口
---
### 问题 4: 图标和文字的动效风格?
**选项:**
- 轻量动效(3秒入场 + 悬停交互)
- 持续循环(呼吸/缩放动画)✅
- 一次性入场
**决策:** 选择**持续循环**,理由:
- 符合"星空宇宙"的神秘感主题
- 视觉冲击力更强,吸引用户注意力
- 不同入口设置不同延迟,形成错落感
---
## 架构设计探索
### 整体架构方案
**方案 A: 单页面组件**
```
WelcomePage.vue
├── 直接写 video 标签
└── 内联功能入口
```
- 优点: 简单直接
- 缺点: 代码耦合,难以复用
**方案 B: 组件化设计 ✅**
```
WelcomePage.vue
├── VideoBackground.vue (通用组件)
└── WelcomeContent.vue
└── WelcomeEntryItem.vue
```
- 优点: 组件复用、职责清晰、易维护
- 缺点: 文件稍多
**决策:** 方案 B,组件化设计
---
### 页面层级结构
```
WelcomePage (视图容器)
├── VideoBackground (简化版背景组件)
│ └── <video> 循环播放,覆盖全屏
└── WelcomeContent (内容悬浮层)
├── [待定] Logo/标题区 (等设计稿确认)
├── 中部: 功能入口网格 (2-3列)
└── [待定] CTA 按钮区 (等设计稿确认)
```
**待确认部分:** 顶部和底部元素需等设计稿确定后再规划
---
### 首次访问控制逻辑
**路由守卫方案:**
```javascript
// src/router/guards.js
const HAS_VISITED_WELCOME = 'has_visited_welcome'
router.beforeEach((to, from, next) => {
// 欢迎页功能开关
if (import.meta.env.VITE_WELCOME_PAGE_ENABLED !== 'true') {
return next()
}
// 首次访问检测
if (to.path !== '/welcome' &&
!localStorage.getItem(HAS_VISITED_WELCOME)) {
localStorage.setItem(HAS_VISITED_WELCOME, 'true')
return next({
path: '/welcome',
query: { redirect: to.fullPath }
})
}
next()
})
```
**调试功能:**
- URL 参数 `?reset_welcome=true` 重置标志位
- 控制台 `window.resetWelcomeFlag()` 快捷方法
---
## 核心组件设计
### VideoBackground 组件
**位置:** `src/components/effects/VideoBackground.vue`
**核心特性:**
- 简化版设计,移除 Canvas 特效
- 原生 `<video>` 标签实现
- 移动端和 PC 端自适应
- 可配置视频源、播放速度、覆盖模式
**Props 设计:**
```javascript
{
videoSrc: { type: String, required: true },
poster: { type: String },
autoplay: { type: Boolean, default: true },
loop: { type: Boolean, default: true },
muted: { type: Boolean, default: true },
objectFit: { type: String, default: 'cover' }
}
```
**关键实现点:**
- 使用 `object-fit: cover` 确保全屏覆盖不变形
- 添加 `playsinline` 支持 iOS 内联播放
- 监听 `canplay` 事件移除 loading 状态
- `z-index: -1` 确保在内容层级下方
---
### WelcomeContent 组件
**位置:** `src/components/welcome/WelcomeContent.vue`
**核心布局:** 功能入口网格(2-3列)
**入口项动效设计(持续循环):**
- CSS `@keyframes` 实现呼吸缩放效果
- 动画参数: `scale(1.0) → scale(1.08) → scale(1.0)`
- 周期: 2-3秒
- 不同入口项设置不同延迟(0s, 0.5s, 1s...)形成错落感
- Hover 时加速或高亮
**CSS 动画示例:**
```less
@keyframes breathe {
0%, 100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
.entry-item {
animation: breathe 2.5s ease-in-out infinite;
animation-delay: calc(var(--index) * 0.3s);
&:hover {
animation-duration: 1s; // 加速
transform: scale(1.1);
}
}
```
---
## 七牛云上传工具设计
### 现有方案分析
**从 deploy.sh 提取的关键逻辑:**
- 使用 `qshell qupload` 命令批量上传
- 依赖配置文件 `~/.qshell/stdj_upload.conf`
- 需要预先安装 qshell 工具
**发现的问题:**
- 用户提到"需要挂代理才能访问"
- 可能是七牛云 API 域名在某些网络环境下被限制
- 需要设置 `HTTP_PROXY`/`HTTPS_PROXY` 环境变量
---
### 通用化设计探索
**原始方案问题:**
- 硬编码了欢迎页特定路径(`/docs/plan/26.1.28-欢迎页开发计划/`
- 配置文件使用项目外的 `~/.qshell/stdj_upload.conf`
- 无法作为通用工具复用
**改进方案:**
1. **项目内配置**
- 配置文件位置: `scripts/qiniu/configs/`
- 账户信息: `scripts/qiniu/account.json`(不入库)
- 配置模板: `scripts/qiniu/templates/*.template`
2. **脚本参数化**
- 支持单文件上传: `./script <local_file> <remote_path>`
- 支持批量上传: `./script <config_file>`
- 环境变量控制代理: `USE_PROXY=true PROXY_HOST=127.0.0.1:7890`
3. **统一账户管理**
- 首次使用时初始化账户
- 账户信息本地加密存储
- 支持多项目共享同一账户
---
### 使用场景设计
**场景 1: 上传欢迎页视频**
```bash
./scripts/upload-to-qiniu.sh video/bg.mp4 mlaj/video/welcome-bg.mp4
```
**场景 2: 批量上传图片**
```bash
# 复制模板并修改配置
cp scripts/qiniu/templates/image-upload.conf.template \
scripts/qiniu/configs/welcome-images.conf
# 执行上传
./scripts/upload-to-qiniu.sh scripts/qiniu/configs/welcome-images.conf
```
**场景 3: 使用代理上传**
```bash
USE_PROXY=true PROXY_HOST="127.0.0.1:7890" \
./scripts/upload-to-qiniu.sh local.mp4 mlaj/video/remote.mp4
```
---
## 风险识别与应对
### 技术风险
**1. 视频播放兼容性**
- iOS Safari 可能禁止自动播放
- 部分浏览器不支持 `playsinline`
**应对:**
```vue
<video
autoplay
loop
muted
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
>
```
- 视频加载失败时使用静态背景图降级
---
**2. 首次访问标志位问题**
- 清除 localStorage 后会再次显示
- 不同浏览器无法共享标志位
**应对:**
- 文档说明这是预期行为
- 提供调试工具方便开发
- 未来可升级为后端接口
---
**3. 视频加载性能**
- 文件过大导致加载缓慢
- 弱网环境体验差
**应对:**
- 限制文件大小 < 10MB
- 提供封面图在视频加载前显示
- 添加加载进度提示
- 考虑视频分段加载或 M3U8
---
**4. 七牛云代理问题**
- 需要挂代理才能访问
**应对:**
- 脚本支持 `USE_PROXY` 环境变量
- 提供诊断脚本测试连通性
- 文档说明代理配置方法
---
### 业务风险
**1. 功能入口待确认**
- 设计稿未确定具体入口
**应对:**
- 先实现框架和核心逻辑
- 入口配置化,后续易于调整
- 使用 Mock 数据开发
---
**2. 动效性能影响**
- 持续动画可能消耗 CPU/电量
**应对:**
- 使用 CSS 动画而非 JS(性能更好)
- 提供环境变量控制动画开关
- 低端设备检测后降级为静态效果
---
## 待确认事项
根据需求文档,以下事项需要确认:
1.**背景视频文件** - `video/?.mp4` 文件名未知
2.**页面效果图** - `img/` 文件夹为空
3.**功能入口列表** - 具体跳转地址未知
4.**页面布局细节** - 顶部/底部是否需要元素(Logo、标语、按钮等)
**建议:** 先完成技术框架和上传工具,等设计稿确认后再填充内容。
---
## 关键决策总结
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 页面目标 | 混合型 | 品牌展示 + 功能引导 |
| 视频实现 | 简化版组件 | 组件化,便于复用 |
| 首次检测 | localStorage | 简单高效 |
| 动效风格 | 持续循环 | 符合主题,视觉冲击力强 |
| 架构设计 | 组件化 | 职责清晰,易维护 |
| 上传工具 | 通用化 | 支持多种场景复用 |
---
## 下一步行动
1. ✅ 完成头脑风暴和设计探索
2. ⏳ 编写详细实现计划(plan.md)
3. ⏳ 等待设计稿确认
4. ⏳ 开始实施开发(按优先级分阶段)
---
*文档创建时间: 2026-01-28*
*最后更新: 2026-01-28*
#!/usr/bin/env bash
#
# 部署脚本:构建项目 -> 上传七牛 -> 打包并上传到服务器
# 说明:在 macOS 环境下执行,依赖 npm、qshell、ssh/scp
set -euo pipefail
# 全局变量(按需修改)
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
server_host="zhsy@oa.jcedu.org"
server_port="12101"
remote_dir="/home/www/f"
local_tar="dist.tar.gz"
local_package_dir="stdj"
qshell_dir="$HOME/.qshell"
qshell_conf="stdj_upload.conf"
build_out_dir="dist"
# 打印信息日志
# 说明:输出带前缀的日志,便于观察执行过程
log_info() {
echo "[deploy] $1"
}
# 执行命令并显示日志
# 参数:cmd 命令字符串
# 说明:包装命令执行,便于统一错误处理与日志输出
run_cmd() {
local cmd="$1"
log_info "执行:$cmd"
eval "$cmd"
}
# 检测构建输出目录
# 说明:按优先级读取 .env.production -> .env -> .env.development 中的 VITE_OUTDIR,缺省为 dist
detect_out_dir() {
# POSIX 兼容实现:按顺序检查 .env.production -> .env -> .env.development
# 若未设置,回退为 dist
local outdir="dist"
for env_file in "$repo_root/.env.production" "$repo_root/.env" "$repo_root/.env.development"; do
if [ -f "$env_file" ]; then
# 提取键值:去注释、去空格、去引号
local value
value=$(grep -E '^\s*VITE_OUTDIR\s*=' "$env_file" | head -n1 | cut -d '=' -f2- | sed 's/#.*$//' | tr -d '[:space:]' | sed -e 's/^"//;s/"$//' -e "s/^'//;s/'$//" || true)
if [ -n "${value:-}" ]; then
outdir="$value"
break
fi
fi
done
echo "$outdir"
}
# 构建项目
# 说明:使用 npm 脚本进行构建,产物位于 dist/
build_project() {
log_info "开始构建项目"
cd "$repo_root"
if command -v pnpm >/dev/null 2>&1; then
run_cmd "pnpm run build"
elif command -v npm >/dev/null 2>&1; then
run_cmd "npm run build"
elif command -v yarn >/dev/null 2>&1; then
run_cmd "yarn build"
else
echo "错误:未检测到 pnpm/npm/yarn,请先安装其中之一"
exit 1
fi
build_out_dir="$(detect_out_dir)"
if [ ! -d "$repo_root/${build_out_dir:-}" ]; then
echo "错误:未找到构建输出目录 '${build_out_dir:-}/',请检查 .env/.env.production 的 VITE_OUTDIR 或构建是否成功"
exit 1
fi
log_info "构建完成:${build_out_dir:-}/"
}
# 准备打包目录
# 说明:将 dist 内容复制到 stdj 目录,用于后续打包
prepare_package_dir() {
log_info "准备打包目录:$local_package_dir"
cd "$repo_root"
# 保留 stdj 目录,仅增量同步构建产物
run_cmd "mkdir -p '$local_package_dir'"
if [ "${build_out_dir:-}" = "$local_package_dir" ]; then
# 输出目录与包目录一致,直接使用,无需复制
log_info "输出目录与包目录一致:${build_out_dir:-},跳过复制"
return 0
fi
if command -v rsync >/dev/null 2>&1; then
# 使用 rsync 增量复制,保留 stdj 目录和其他文件(不删除已有文件)
run_cmd "rsync -a '${build_out_dir:-}/' '$local_package_dir/'"
else
# 回退到 cp -R,同样保留 stdj 目录
run_cmd "cp -R '${build_out_dir:-}'/. '$local_package_dir'/"
fi
}
# 上传静态资源到七牛云
# 说明:进入 ~/.qshell 并执行 qupload,同步 upload.conf 中配置的资源
upload_qiniu() {
log_info "检查 qshell 安装与配置"
if ! command -v qshell >/dev/null 2>&1; then
echo "错误:未检测到 qshell,请先安装 https://developer.qiniu.com/kodo/tools/1302/qshell"
exit 1
fi
if [ ! -d "$qshell_dir" ]; then
echo "错误:未找到目录 $qshell_dir"
exit 1
fi
if [ ! -f "$qshell_dir/$qshell_conf" ]; then
echo "错误:未找到七牛配置文件 $qshell_dir/$qshell_conf"
exit 1
fi
log_info "开始上传七牛云资源(qupload)"
cd "$qshell_dir"
# 说明:部分文件已存在会导致 qshell 返回非零退出码,此处不终止流程
log_info "执行:qshell qupload '$qshell_conf'"
if ! qshell qupload "$qshell_conf"; then
log_info "qshell qupload 退出码非零(可能因文件已存在),继续后续部署"
fi
log_info "七牛云资源上传完成"
}
# 打包本地目录并上传到服务器
# 说明:打包 stdj -> 通过 scp 上传到指定目录 -> 远端解压并删除压缩包
upload_server() {
log_info "开始打包:${local_tar:-}"
cd "$repo_root"
run_cmd "tar -czvpf '${local_tar:-}' '${local_package_dir:-}'"
log_info "上传到服务器:${server_host:-}:${remote_dir:-}(端口 ${server_port:-})"
run_cmd "scp -P '${server_port:-}' '${local_tar:-}' '${server_host:-}':'${remote_dir:-}'"
log_info "服务器解压:${remote_dir:-}/${local_tar:-}"
# 说明:构造远端命令,避免变量名意外字符导致的未绑定错误
local remote_cmd
remote_cmd="cd \"${remote_dir:-}\" && tar -xzvf \"${local_tar:-}\" && rm -rf \"${local_tar:-}\""
run_cmd "ssh -p '${server_port:-}' '${server_host:-}' \"$remote_cmd\""
log_info "删除本地压缩包"
run_cmd "rm -f '${local_tar:-}'"
}
# 主流程
# 说明:串行执行:构建 -> 七牛上传 -> 服务器打包上传
main() {
build_project
prepare_package_dir
upload_qiniu
upload_server
log_info "部署完成"
}
main "$@"
# 欢迎页功能 - 详细实现计划
## 项目概述
为 mlaj 平台开发一个欢迎页,作为用户首次进入时的引导页面,展示核心功能入口。
**核心功能:**
- 视频背景循环播放(星空宇宙主题)
- 悬浮的功能入口图标 + 文字介绍
- 持续循环的缩放位移动效
- 首次进入检测,非首次直接跳转主页
- 通用七牛云上传工具
---
## 目录结构
```
mlaj/
├── src/
│ ├── components/
│ │ ├── effects/
│ │ │ └── VideoBackground.vue # 视频背景组件
│ │ └── welcome/
│ │ ├── WelcomeContent.vue # 内容容器组件
│ │ └── WelcomeEntryItem.vue # 功能入口项组件
│ ├── config/
│ │ └── welcomeEntries.js # 功能入口配置
│ ├── router/
│ │ ├── routes.js # 新增欢迎页路由
│ │ └── guards.js # 修改路由守卫
│ └── views/
│ └── welcome/
│ └── WelcomePage.vue # 欢迎页主视图
├── scripts/
│ ├── upload-to-qiniu.sh # 七牛云上传工具
│ └── qiniu/
│ ├── account.json # 账户信息(不入库)
│ ├── templates/ # 配置模板
│ │ ├── video-upload.conf.template
│ │ └── image-upload.conf.template
│ └── configs/ # 实际配置(不入库)
│ └── welcome-video.conf
└── docs/
└── plan/
└── 26.1.28-欢迎页开发计划/
├── plan.md # 本文档
└── brainstorm.md # 头脑风暴记录
```
---
## 开发步骤
### 第 0 阶段: 准备工作 (优先级: 🔴 高)
**目标:** 准备开发环境和资源
#### 步骤 1: 创建配置文件模板
**创建 `scripts/qiniu/templates/video-upload.conf.template`:**
```json
{
"src_dir": "./assets/video",
"bucket": "mlaj",
"key_prefix": "mlaj/video/",
"ignore_dir": false,
"overwrite": true,
"check_exists": true,
"check_hash": true,
"rescan_local": true,
"skip_file_prefixes": ".",
"skip_suffixes": ".DS_Store",
"up_host": "https://upload.qiniup.com",
"file_type": 0
}
```
**创建 `scripts/qiniu/templates/image-upload.conf.template`:**
```json
{
"src_dir": "./assets/images",
"bucket": "mlaj",
"key_prefix": "mlaj/images/",
"ignore_dir": false,
"overwrite": true,
"check_exists": true,
"check_hash": true,
"rescan_local": true,
"skip_file_prefixes": ".",
"skip_suffixes": ".DS_Store",
"up_host": "https://upload.qiniup.com",
"file_type": 0
}
```
#### 步骤 2: 更新 .gitignore
```gitignore
# 七牛账户信息(敏感)
scripts/qiniu/account.json
# 实际配置文件(可能包含路径信息)
scripts/qiniu/configs/
```
#### 步骤 3: 准备视频资源
-`docs/plan/26.1.28-欢迎页开发计划/video/` 添加背景视频
- 建议规格:
- 分辨率: 1920x1080 (1080p)
- 编码格式: H.264
- 时长: 10-20秒循环视频
- 文件大小: < 10MB
#### 步骤 4: 上传资源到七牛云
```bash
# 初始化七牛账户
chmod +x scripts/upload-to-qiniu.sh
./scripts/upload-to-qiniu.sh init
# 上传视频(如果有代理)
USE_PROXY=true PROXY_HOST="127.0.0.1:7890" \
./scripts/upload-to-qiniu.sh video/background.mp4 mlaj/video/welcome-background.mp4
```
#### 步骤 5: 更新环境变量
**`.env.development`:**
```bash
# 欢迎页功能开关
VITE_WELCOME_PAGE_ENABLED=true
VITE_WELCOME_VIDEO_URL=https://cdn.ipadbiz.cn/mlaj/video/welcome-background.mp4
VITE_WELCOME_VIDEO_POSTER=https://cdn.ipadbiz.cn/mlaj/images/welcome-poster.jpg
```
**`.env.production`:**
```bash
VITE_WELCOME_PAGE_ENABLED=true
VITE_WELCOME_VIDEO_URL=https://cdn.ipadbiz.cn/mlaj/video/welcome-background.mp4
VITE_WELCOME_VIDEO_POSTER=https://cdn.ipadbiz.cn/mlaj/images/welcome-poster.jpg
```
---
### 第 1 阶段: 通用上传工具开发 (优先级: 🔴 高)
**目标:** 实现可复用的七牛云上传工具
#### 步骤 1: 创建上传脚本
**文件:** `scripts/upload-to-qiniu.sh`
```bash
#!/usr/bin/env bash
# 通用七牛云上传工具 - mlaj 项目
# 用法: ./scripts/upload-to-qiniu.sh <local_file> <remote_path>
set -euo pipefail
# 项目根目录
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
# 七牛配置
QINIU_BUCKET="${QINIU_BUCKET:-mlaj}"
QINIU_CONFIG_DIR="$repo_root/scripts/qiniu"
QINIU_ACCOUNT_CONF="account.json"
# 代理设置(可选)
USE_PROXY=${USE_PROXY:-false}
PROXY_HOST=${PROXY_HOST:-"127.0.0.1:7890"}
log_info() {
echo "[qiniu-upload] $1"
}
# 初始化 qshell 账户
init_account() {
if ! command -v qshell >/dev/null 2>&1; then
echo "错误: 未检测到 qshell,请先安装 https://developer.qiniu.com/kodo/tools/1302/qshell"
exit 1
fi
if [ ! -f "$QINIU_CONFIG_DIR/$QINIU_ACCOUNT_CONF" ]; then
log_info "首次使用,请输入七牛云账号信息:"
read -p "Access Key: " AK
read -p "Secret Key: " SK
mkdir -p "$QINIU_CONFIG_DIR"
qshell account "$AK" "$SK" > "$QINIU_CONFIG_DIR/$QINIU_ACCOUNT_CONF"
log_info "账户信息已保存到 $QINIU_CONFIG_DIR/$QINIU_ACCOUNT_CONF"
fi
}
# 单文件上传
upload_single_file() {
local local_file="$1"
local remote_path="$2"
if [ ! -f "$local_file" ]; then
echo "错误: 文件不存在 $local_file"
exit 1
fi
# 获取文件目录和文件名
local file_dir=$(cd "$(dirname "$local_file")" && pwd)
local file_name=$(basename "$local_file")
# 创建临时配置
local temp_conf="$QINIU_CONFIG_DIR/temp_upload_$(date +%s).conf"
cat > "$temp_conf" << EOF
{
"src_dir": "$file_dir",
"bucket": "$QINIU_BUCKET",
"key_prefix": "$(dirname "$remote_path")/",
"ignore_dir": false,
"overwrite": true,
"check_exists": true,
"check_hash": true,
"rescan_local": false,
"skip_file_prefixes": ".",
"skip_suffixes": ".DS_Store",
"up_host": "https://upload.qiniup.com",
"file_type": 0,
"file_list": [
"$file_name"
]
}
EOF
execute_upload "$temp_conf"
# 清理临时配置
rm -f "$temp_conf"
log_info "✅ 上传成功: https://cdn.ipadbiz.cn/$remote_path"
}
# 批量上传(使用配置文件)
upload_batch() {
local config_file="$1"
if [ ! -f "$config_file" ]; then
echo "错误: 配置文件不存在 $config_file"
exit 1
fi
execute_upload "$config_file"
log_info "✅ 批量上传完成"
}
# 执行上传(统一处理代理)
execute_upload() {
local config_file="$1"
if [ "$USE_PROXY" = "true" ]; then
export HTTP_PROXY="http://$PROXY_HOST"
export HTTPS_PROXY="http://$PROXY_HOST"
log_info "使用代理: $PROXY_HOST"
fi
qshell qupload "$config_file"
}
# 显示帮助信息
show_help() {
cat << EOF
通用七牛云上传工具
用法:
$0 <local_file> <remote_path> 单文件上传
$0 <config_file> 批量上传(指定配置文件)
$0 init 初始化七牛账户
$0 help 显示此帮助信息
参数说明:
local_file 本地文件路径(相对或绝对路径)
remote_path 远程路径,如: mlaj/video/bg.mp4
config_file 配置文件路径
环境变量:
QINIU_BUCKET 七牛空间名(默认: mlaj)
USE_PROXY=true 启用代理
PROXY_HOST=127.0.0.1:7890 代理地址
示例:
# 单文件上传
$0 ./assets/video/bg.mp4 mlaj/video/welcome-bg.mp4
# 批量上传
$0 scripts/qiniu/configs/welcome-video.conf
# 使用代理上传
USE_PROXY=true $0 ./local/file.mp4 mlaj/video/file.mp4
配置文件格式:
{
"src_dir": "./assets/video",
"bucket": "mlaj",
"key_prefix": "mlaj/video/",
"overwrite": true,
"check_exists": true
}
EOF
}
# 主逻辑
main() {
init_account
case "${1:-}" in
init)
log_info "账户初始化完成"
;;
help|--help|-h)
show_help
;;
"")
show_help
exit 1
;;
*)
if [ $# -eq 1 ]; then
# 单个参数,视为配置文件
upload_batch "$1"
elif [ $# -eq 2 ]; then
# 两个参数,单文件上传
upload_single_file "$1" "$2"
else
echo "错误: 参数数量不正确"
show_help
exit 1
fi
;;
esac
}
main "$@"
```
#### 步骤 2: 添加 npm scripts
**`package.json`:**
```json
{
"scripts": {
"upload:qiniu": "bash scripts/upload-to-qiniu.sh",
"qiniu:init": "bash scripts/upload-to-qiniu.sh init"
}
}
```
#### 步骤 3: 测试上传工具
```bash
# 赋予执行权限
chmod +x scripts/upload-to-qiniu.sh
# 初始化账户
pnpm run qiniu:init
# 测试单文件上传
pnpm run upload:qiniu ./test/file.mp4 mlaj/video/test.mp4
# 测试代理上传(如果需要)
USE_PROXY=true pnpm run upload:qiniu ./test/file.mp4 mlaj/video/test.mp4
```
---
### 第 2 阶段: VideoBackground 组件 (优先级: 🔴 高)
**目标:** 实现视频背景组件
#### 步骤 1: 创建组件文件
**文件:** `src/components/effects/VideoBackground.vue`
```vue
<template>
<div class="video-background">
<!-- Loading 状态 -->
<div v-if="isLoading" class="video-loading">
<van-loading size="24px">加载中...</van-loading>
</div>
<!-- 视频元素 -->
<video
ref="videoRef"
class="video-element"
:src="videoSrc"
:poster="poster"
: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(${poster || videoSrc})` }"
></div>
</div>
</template>
<script setup>
import { ref, 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)
// 视频可以播放时
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>
```
#### 步骤 2: 组件使用示例
```vue
<template>
<VideoBackground
:video-src="videoUrl"
:poster="posterUrl"
autoplay
loop
muted
/>
</template>
<script setup>
import VideoBackground from '@/components/effects/VideoBackground.vue'
const videoUrl = 'https://cdn.ipadbiz.cn/mlaj/video/welcome-background.mp4'
const posterUrl = 'https://cdn.ipadbiz.cn/mlaj/images/welcome-poster.jpg'
</script>
```
---
### 第 3 阶段: 路由与首次访问逻辑 (优先级: 🔴 高)
**目标:** 实现路由守卫和首次访问检测
#### 步骤 1: 创建欢迎页视图
**文件:** `src/views/welcome/WelcomePage.vue`
```vue
<template>
<div class="welcome-page">
<!-- 视频背景 -->
<VideoBackground
v-if="videoUrl"
:video-src="videoUrl"
:poster="posterUrl"
/>
<!-- 内容区域 -->
<WelcomeContent class="welcome-content" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import VideoBackground from '@/components/effects/VideoBackground.vue'
import WelcomeContent from '@/components/welcome/WelcomeContent.vue'
const route = useRoute()
const videoUrl = computed(() => {
return import.meta.env.VITE_WELCOME_VIDEO_URL || ''
})
const posterUrl = computed(() => {
return import.meta.env.VITE_WELCOME_VIDEO_POSTER || ''
})
</script>
<style scoped>
.welcome-page {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
}
.welcome-content {
position: relative;
z-index: 1;
}
</style>
```
#### 步骤 2: 添加路由配置
**文件:** `src/router/routes.js`
```javascript
// 在路由数组中添加
{
path: '/welcome',
name: 'Welcome',
component: () => import('@/views/welcome/WelcomePage.vue'),
meta: {
requiresAuth: false,
hideInMenu: true
}
}
```
#### 步骤 3: 实现首次访问检测
**文件:** `src/router/guards.js`
```javascript
// 首次访问标志
const HAS_VISITED_WELCOME = 'has_visited_welcome'
const WELCOME_VISITED_AT = 'welcome_visited_at'
/**
* 检查用户是否已访问过欢迎页
*/
export function hasVisitedWelcome() {
return localStorage.getItem(HAS_VISITED_WELCOME) === 'true'
}
/**
* 标记用户已访问欢迎页
*/
export function markWelcomeVisited() {
localStorage.setItem(HAS_VISITED_WELCOME, 'true')
localStorage.setItem(WELCOME_VISITED_AT, Date.now().toString())
}
/**
* 重置欢迎页标志(用于调试)
*/
export function resetWelcomeFlag() {
localStorage.removeItem(HAS_VISITED_WELCOME)
localStorage.removeItem(WELCOME_VISITED_AT)
}
// 在路由守卫中添加首次访问检测
router.beforeEach((to, from, next) => {
// 欢迎页功能开关
if (import.meta.env.VITE_WELCOME_PAGE_ENABLED !== 'true') {
return next()
}
// 重置欢迎页标志(URL 参数)
if (to.query.reset_welcome === 'true') {
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 }
})
}
next()
})
```
#### 步骤 4: 添加调试工具
**文件:** `src/main.js` (或 `App.vue`)
```javascript
// 开发环境添加调试工具
if (import.meta.env.DEV) {
window.resetWelcomeFlag = () => {
localStorage.removeItem('has_visited_welcome')
localStorage.removeItem('welcome_visited_at')
console.log('✅ 欢迎页标志已重置,请刷新页面查看欢迎页')
}
window.showWelcome = () => {
window.location.href = '/welcome'
}
console.log('🔧 开发工具:')
console.log(' - window.resetWelcomeFlag() 重置欢迎页标志')
console.log(' - window.showWelcome() 跳转到欢迎页')
}
```
---
### 第 4 阶段: WelcomeContent 组件 (优先级: 🟡 中)
**目标:** 实现内容悬浮层
#### 步骤 1: 创建功能入口配置
**文件:** `src/config/welcomeEntries.js`
```javascript
/**
* 欢迎页功能入口配置
* 待设计稿确认后更新具体内容
*/
export const welcomeEntries = [
{
id: 'courses',
title: '课程中心',
subtitle: '探索精选课程',
icon: '📚',
route: '/courses',
color: '#4CAF50',
priority: 1
},
{
id: 'checkin',
title: '每日打卡',
subtitle: '记录学习点滴',
icon: '✅',
route: '/checkin',
color: '#2196F3',
priority: 2
}
// ... 更多入口(等设计稿确认)
]
/**
* 根据优先级排序
*/
export function getSortedEntries() {
return [...welcomeEntries].sort((a, b) => a.priority - b.priority)
}
```
#### 步骤 2: 创建功能入口项组件
**文件:** `src/components/welcome/WelcomeEntryItem.vue`
```vue
<template>
<div
class="entry-item"
:style="{ '--index': index }"
@click="handleClick"
>
<!-- 图标 -->
<div class="entry-icon" :style="{ color: entry.color }">
{{ entry.icon }}
</div>
<!-- 标题 -->
<div class="entry-title">
{{ entry.title }}
</div>
<!-- 副标题(可选) -->
<div v-if="entry.subtitle" class="entry-subtitle">
{{ entry.subtitle }}
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const props = defineProps({
entry: {
type: Object,
required: true
},
index: {
type: Number,
required: true
}
})
const router = useRouter()
const handleClick = () => {
const redirect = new URLSearchParams(window.location.search).get('redirect')
if (props.entry.route) {
router.push(props.entry.route)
} else if (redirect) {
router.push(redirect)
} else {
router.push('/')
}
}
</script>
<style scoped lang="less">
.entry-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
cursor: pointer;
user-select: none;
transition: all 0.3s ease;
// 呼吸动画
animation: breathe 2.5s ease-in-out infinite;
animation-delay: calc(var(--index) * 0.3s);
&:hover {
animation-duration: 1s;
transform: scale(1.1);
.entry-icon {
transform: scale(1.2);
}
}
&:active {
transform: scale(0.95);
}
}
@keyframes breathe {
0%, 100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
.entry-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
transition: transform 0.3s ease;
}
.entry-title {
font-size: 1.125rem;
font-weight: 600;
color: #fff;
text-align: center;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
margin-bottom: 0.25rem;
}
.entry-subtitle {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
</style>
```
#### 步骤 3: 创建内容容器组件
**文件:** `src/components/welcome/WelcomeContent.vue`
```vue
<template>
<div class="welcome-content">
<!-- [待定] Logo/标题区 -->
<!-- 功能入口网格 -->
<div class="entries-grid">
<WelcomeEntryItem
v-for="(entry, index) in sortedEntries"
:key="entry.id"
:entry="entry"
:index="index"
/>
</div>
<!-- [待定] CTA 按钮区 -->
</div>
</template>
<script setup>
import { computed } from 'vue'
import { getSortedEntries } from '@/config/welcomeEntries'
import WelcomeEntryItem from './WelcomeEntryItem.vue'
const sortedEntries = computed(() => getSortedEntries())
</script>
<style scoped lang="less">
.welcome-content {
position: relative;
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.entries-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
width: 100%;
max-width: 600px;
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
max-width: 900px;
}
}
</style>
```
---
### 第 5 阶段: 测试与优化 (优先级: 🟡 中)
#### 功能测试清单
- [ ] 首次访问正确跳转到欢迎页
- [ ] 再次访问直接进入主页
- [ ] `?reset_welcome=true` 正确重置标志
- [ ] 点击功能入口正确跳转
- [ ] 视频背景正常循环播放
- [ ] 视频加载失败时显示降级方案
- [ ] 清除 localStorage 后重新访问
#### 兼容性测试
- [ ] iOS Safari (video autoplay, playsinline)
- [ ] Android Chrome
- [ ] 微信内置浏览器
- [ ] PC 端浏览器 (Chrome, Firefox, Safari, Edge)
#### 性能测试
- [ ] 视频首次加载时间 < 3s
- [ ] 动画帧率 > 50fps
- [ ] 内存占用正常
- [ ] CPU 占用正常
#### 边界测试
- [ ] 视频加载失败时降级为静态图
- [ ] 网络慢速时的表现
- [ ] 清除缓存后的行为
- [ ] URL 参数异常处理
---
### 第 6 阶段: 文档与部署 (优先级: 🟢 低)
#### 步骤 1: 更新项目文档
**`CLAUDE.md` 添加说明:**
```markdown
### 欢迎页
- **功能**: 用户首次进入时的引导页面,展示核心功能入口
- **路由**: `/welcome`
- **开关**: `VITE_WELCOME_PAGE_ENABLED=true`
- **首次检测**: 使用 localStorage 标志 `has_visited_welcome`
- **调试方法**:
- URL 参数 `?reset_welcome=true` 重置标志
- 控制台 `window.resetWelcomeFlag()` 重置标志
- 控制台 `window.showWelcome()` 跳转欢迎页
```
#### 步骤 2: 创建使用说明
**文件:** `docs/welcome-page-guide.md`
```markdown
# 欢迎页使用指南
## 功能说明
欢迎页是用户首次进入平台时的引导页面,展示核心功能入口。
## 开发配置
### 环境变量
```bash
# 启用欢迎页
VITE_WELCOME_PAGE_ENABLED=true
# 视频资源
VITE_WELCOME_VIDEO_URL=https://cdn.ipadbiz.cn/mlaj/video/welcome-background.mp4
VITE_WELCOME_VIDEO_POSTER=https://cdn.ipadbiz.cn/mlaj/images/welcome-poster.jpg
```
### 功能入口配置
编辑 `src/config/welcomeEntries.js`:
```javascript
export const welcomeEntries = [
{
id: 'courses',
title: '课程中心',
subtitle: '探索精选课程',
icon: '📚',
route: '/courses',
color: '#4CAF50',
priority: 1
}
// ... 更多入口
]
```
## 调试方法
### 重置欢迎页标志
**方法 1: URL 参数**
```
http://localhost:5173/?reset_welcome=true
```
**方法 2: 控制台**
```javascript
window.resetWelcomeFlag()
location.reload()
```
### 直接访问欢迎页
```javascript
window.showWelcome()
```
## 上传新的背景视频
### 使用上传工具
```bash
# 单文件上传
pnpm run upload:qiniu ./local/video.mp4 mlaj/video/welcome-background.mp4
# 使用代理上传
USE_PROXY=true pnpm run upload:qiniu ./local/video.mp4 mlaj/video/welcome-background.mp4
```
### 视频规格建议
- 分辨率: 1920x1080 (1080p)
- 编码格式: H.264
- 时长: 10-20秒循环
- 文件大小: < 10MB
```
#### 步骤 3: 部署到开发环境
```bash
# 构建
pnpm build
# 部署到开发服务器
pnpm dev_upload
```
#### 步骤 4: 测试生产环境
- 验证视频 CDN 加载速度
- 验证首次访问逻辑
- 验证兼容性
- 收集用户反馈
---
## 技术要点
### 1. 视频播放兼容性
```vue
<video
autoplay
loop
muted
playsinline
webkit-playsinline
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
>
```
**关键属性说明:**
- `muted`: 静音播放(移动端自动播放必需)
- `playsinline`: iOS 内联播放
- `webkit-playsinline`: iOS Safari 兼容
- `x5-video-player-type`: 腾讯 X5 内核(微信/QQ浏览器)
### 2. 动画性能优化
```less
// 使用 CSS 动画而非 JS 动画
animation: breathe 2.5s ease-in-out infinite;
// 使用 transform 而非 width/height
transform: scale(1.08);
// 使用 GPU 加速
will-change: transform, opacity;
```
### 3. 降级方案
```vue
<!-- 视频加载失败时显示静态背景图 -->
<div
v-if="showFallback"
class="video-fallback"
:style="{ backgroundImage: `url(${poster})` }"
></div>
```
---
## 风险与注意事项
### 技术风险
1. **视频播放兼容性**
- iOS Safari 可能禁止自动播放
- 解决方案: 添加 `muted` 属性,提供降级方案
2. **首次访问标志位**
- 清除 localStorage 后会再次显示
- 解决方案: 文档说明,未来升级为后端接口
3. **视频加载性能**
- 文件过大导致加载缓慢
- 解决方案: 限制文件大小 < 10MB,提供封面图
4. **七牛云代理问题**
- 需要挂代理才能访问
- 解决方案: 支持 `USE_PROXY` 环境变量
### 业务风险
1. **功能入口待确认**
- 先实现框架,入口配置化
2. **动效性能影响**
- 使用 CSS 动画,提供环境变量控制
---
## 优先级总结
### 第 1 批 (核心功能) 🔴
- ✅ 第 0 阶段: 准备工作
- ✅ 第 1 阶段: 通用上传工具
- ✅ 第 2 阶段: VideoBackground 组件
- ✅ 第 3 阶段: 路由与首次访问逻辑
### 第 2 批 (功能完善) 🟡
- ⏳ 第 4 阶段: WelcomeContent 组件
- ⏳ 第 5 阶段: 测试与优化
### 第 3 批 (收尾工作) 🟢
- ⏳ 第 6 阶段: 文档与部署
---
## 待确认事项
1.**背景视频文件** - 需要提供视频资源
2.**页面效果图** - 需要设计稿确认布局
3.**功能入口列表** - 需要确认具体入口和跳转地址
4.**页面布局细节** - 顶部/底部是否需要元素
**建议:** 先完成技术框架和上传工具,等设计稿确认后再填充内容。
---
*文档创建时间: 2026-01-28*
*最后更新: 2026-01-28*