项目介绍
npm i -g @vue/cli
vue create youtube-frontend
导入写好的前端模板页面
封装请求模块
配置接口服务地址:
.env.development
# npm run serve 加载的环境变量配置文件
VUE_APP_API_BASE_URL=http://127.0.0.1:7001/
.env.production
# npm run build 加载的这个配置文件
VUE_APP_API_BASE_URL=http://youtubeclone.lipengzhou.com/
api/request.ts
import axios from 'axios'
import { store } from '@/store'
// 创建axios实例
export const request = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL // 获取对应环境的服务地址。也就是:env.development的值
})
// 请求拦截器
// 响应拦截器
用户登录-封装请求接口
api/user.js
import { request } from '@/utils/request'
interface LoginInput {
email: string
password: string
}
export interface User {
email: string
token: string
username: string
channelDescription?: string
avatar?: string
}
interface LoginPayload {
user: User
}
export const login = (data: LoginInput) => {
return request.post<LoginPayload>('/api/v1/users/login', data)
}
用户登录-基本流程
login/index.vue
<template>
<div class="gspRov">
<h2>Login to your account</h2>
<form @submit.prevent="handleSubmit">
<ul v-if="errors" class="errors">
<li v-for="(error, index) in errors" :key="index">
<!-- 显示错误消息 -->
{{ `${error.field} ${error.message}` }}
</li>
</ul>
<input v-model="user.email" type="email" placeholder="email" />
<input v-model="user.password" type="password" placeholder="password" />
<div class="action input-group">
<span class="pointer">Signup instead</span>
<button :disabled="isLoading">Login</button>
</div>
</form>
</div>
</template>
<script lang="ts">
import { login } from '@/api/user'
import { defineComponent, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from '@/store'
const useLogin = () => {
// 在vue3.0的setup中没有this,因此this.$router.push()在3.0中不能使用。
// 需要这样写:useRouter(), 这样就可以得到路由实例了。
const router = useRouter() // 获取router实例
const store = useStore()
const route = useRoute() // 获取route实例
// reactive: 定义响应式数据
const user = reactive({
email: 'lpzmail@163.com',
password: '123456'
})
// ref:定义errors变量而且是响应式的。ref定义的响应式数据需要在使用.value获取。
const errors = ref([])
const isLoading = ref(false)
// 提交
const handleSubmit = async () => {
isLoading.value = true
errors.value = []
// try catch捕获异常
try {
// 发送请求
const { data } = await login(user)
router.push('/')
} catch (err) {
if (err.response.status === 422) {
errors.value = err.response.data.detail
}
}
isLoading.value = false
}
// 1.只有在这个地方return之后,才可以在template模版中使用。2.return中导出的数据在模版中可以直接使用。
return {
user,
handleSubmit,
errors,
isLoading
}
}
export default defineComponent({
name: 'LoginIndex',
setup () {
return {
...useLogin()
}
}
})
</script>
配置Vuex中的State支持TS类型推断
store/index.ts
import { User } from '@/api/user'
import { InjectionKey } from 'vue'
import { createStore, Store, useStore as baseUseStore } from 'vuex'
// 声明 State 类型
export interface State {
count: number
user: User | null
}
// define injection key
export const key: InjectionKey<Store<State>> = Symbol()
export const store = createStore<State>({
state: {
count: 123,
// 身份认证-存储用户登录信息
user: JSON.parse(window.localStorage.getItem('user') || 'null')
},
// 修改state
mutations: {
setUser (state, user: User) {
state.user = user
// 身份认证-存储用户登录信息
window.localStorage.setItem('user', JSON.stringify(state.user))
}
}
})
// define your own `useStore` composition function
// 不用每次使用的使用都传入key
export function useStore () {
return baseUseStore(key)
}
main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
+ import { store, key } from './store'
createApp(App)
+ .use(store, key)
.use(router)
.mount('#app')
身份认证-统一添加token
import axios from 'axios'
import { store } from '@/store'
// 创建axios实例
export const request = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL // 获取对应环境的服务地址。也就是:env.development的值
})
// 请求拦截器
+ request.interceptors.request.use(config => {
// config:本次的请求对象
// 如果有user,则是登录状态
+ const { user } = store.state
+ if (user) {
// 身份认证-统一添加token
+ config.headers.Authorization = `Bearer ${user.token}`
}
+ return config
+ }, err => {
+ return Promise.reject(err)
+ })
// 响应拦截器
身份认证-处理页面访问权限
router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import AppLayout from '@/layout/AppLayout.vue'
import { store } from '@/store'
// 路由规则表
const routes: Array<RouteRecordRaw> = [
{
path: '/',
component: AppLayout,
children: [
{
path: '', // 默认子路由
name: 'home',
component: () => import(/* webpackChunkName: "home" */ '@/views/home/index.vue')
},
{
path: 'profile',
name: 'profile',
component: () => import(/* webpackChunkName: "profile" */ '@/views/profile/index.vue'),
// 必须是登录了,才可以登录
+ meta: { requiresAuth: true }
},
{
path: 'watch/:videoId',
name: 'watch',
component: () => import(/* webpackChunkName: "video" */ '@/views/watch/index.vue')
}
]
},
{
path: '/login',
name: 'login',
component: () => import(/* webpackChunkName: "login" */ '@/views/login/index.vue')
}
]
// 创建路由实例
const router = createRouter({
history: createWebHashHistory(),
routes
})
+ // 每当请求进来,就会经过这个路由拦截先处理。
+ // 请求拦截,如果没有登录,则不允许访问前端路由。
+ // meta.requiresAuth为true需要拦截是否登录状态
router.beforeEach((to, from, next) => {
const { user } = store.state
+ if (to.matched.some(record => record.meta.requiresAuth)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
+ if (!user) {
+ next({
+ path: '/login',
+ query: { redirect: to.fullPath }
+ })
+ } else {
next()
}
} else {
next() // 确保一定要调用 next()
}
})
export default router
login/index.vue
<template>
<div class="gspRov">
<h2>Login to your account</h2>
<form @submit.prevent="handleSubmit">
<ul v-if="errors" class="errors">
<li v-for="(error, index) in errors" :key="index">
<!-- 显示错误消息 -->
{{ `${error.field} ${error.message}` }}
</li>
</ul>
<input v-model="user.email" type="email" placeholder="email" />
<input v-model="user.password" type="password" placeholder="password" />
<div class="action input-group">
<span class="pointer">Signup instead</span>
<button :disabled="isLoading">Login</button>
</div>
</form>
</div>
</template>
<script lang="ts">
import { login } from '@/api/user'
import { defineComponent, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from '@/store'
const useLogin = () => {
// 在vue3.0的setup中没有this,因此this.$router.push()在3.0中不能使用。
// 需要这样写:useRouter(), 这样就可以得到路由实例了。
const router = useRouter() // 获取router实例
const store = useStore()
const route = useRoute() // 获取route实例
// reactive: 定义响应式数据
const user = reactive({
email: 'lpzmail@163.com',
password: '123456'
})
// ref:定义errors变量而且是响应式的。ref定义的响应式数据需要在使用.value获取。
const errors = ref([])
const isLoading = ref(false)
// 提交
const handleSubmit = async () => {
isLoading.value = true
errors.value = []
// try catch捕获异常
try {
// 发送请求
const { data } = await login(user)
// 接口返回的数据data存储到store中
+ store.commit('setUser', data.user)
+ const redirect = (route.query.redirect || '/') as string // as string强制转换为字符串。
router.push(redirect)
} catch (err) {
if (err.response.status === 422) {
errors.value = err.response.data.detail
}
}
isLoading.value = false
}
// 1.只有在这个地方return之后,才可以在template模版中使用。2.return中导出的数据在模版中可以直接使用。
return {
user,
handleSubmit,
errors,
isLoading
}
}
export default defineComponent({
name: 'LoginIndex',
setup () {
return {
...useLogin()
}
}
})
</script>
身份认证-处理头部内容展示状态
可以直接在模版中这样使用store:$store.state.user
app/loayout/AppHeader.vue
<template>
<div class="sc-AxhUy JixXX">
<div class="logo flex-row">
<span><a href="/">YouTube Clone</a></span>
</div>
<div class="sc-AxjAm iVYHdX">
<input class="search" type="text" placeholder="Search" value="" />
</div>
<ul>
+ <template v-if="$store.state.user">
<li>
<div>
<label for="video-upload" @click="isUploadShow = true">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
height="27"
width="27"
fill="#FFF"
focusable="false"
>
<g>
<path
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4zM14 13h-3v3H9v-3H6v-2h3V8h2v3h3v2z"
></path>
</g>
</svg>
</label>
</div>
</li>
<li>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
height="27"
fill="#FFF"
width="27"
>
<g>
<path
d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"
></path>
</g>
</svg>
</li>
<li>
<a href="/feed/my_videos">
<img
class="sc-AxhCb eSwYtm pointer"
src="https://res.cloudinary.com/douy56nkf/image/upload/v1594060920/defaults/txxeacnh3vanuhsemfc8.png"
alt="user-avatar"
/></a>
</li>
</template>
+ <template v-else>
<li>
<a href="">
<span>SIGN IN</span>
</a>
</li>
<li>
<a href="">
<span>SIGN UP</span>
</a>
</li>
</template>
</ul>
</div>
<upload-video v-if="isUploadShow" @close="isUploadShow = false"/>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import UploadVideo from '@/components/UploadVideo/index.vue'
export default defineComponent({
name: 'AppHeader',
components: {
UploadVideo
},
setup () {
// 初始化变量:isUploadShow, ref设置响应式数据
const isUploadShow = ref(false)
return {
isUploadShow
}
}
})
</script>
创建视频-预览视频
component/UploadViode/index.vue
<template>
<div class="sc-AxiKw dZbDOR">
<div class="modal-content">
<form @submit.prevent="handleSubmit">
<div class="modal-header">
<div class="modal-header-left">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
@click="handleClose"
>
<g>
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
></path>
</g>
</svg>
<h3>Upload Video</h3>
</div>
<div style="display: block">
<button class="sc-AxirZ erzyjX">Save</button>
</div>
</div>
<div class="tab video-form">
<input ref="file" required type="file" @change="handleFileChange" />
// 预览视频
<video ref="videoEl" controls></video>
<input v-model="video.title" required type="text" placeholder="Enter the title" />
<textarea
v-model="video.description"
required
placeholder="Tell viewers about your video"
></textarea>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, ref } from 'vue'
import { createUploadVideo, refreshUploadVideo } from '@/api/vod'
import { createVideo } from '@/api/video'
import router from '@/router'
import { useRouter } from 'vue-router'
export default defineComponent({
name: 'UploadVideo',
setup (props, context) {
const router = useRouter()
// 上传的video
+ const file = ref(null)
// 预览的video
+ const videoEl = ref(null)
// 关闭弹出框
const handleClose = () => {
// 对外发布一个自定义事件
context.emit('close')
}
// 上传文件
+ const handleFileChange = () => {
+ const fileObj = (file.value as any).files[0];
// (videoEl.value as any): videoEl.value转换为any类型
+ (videoEl.value as any).src = URL.createObjectURL(fileObj)
}
return {
handleClose,
file,
+ handleFileChange,
}
}
})
</script>
创建视频-视频上传-初始化上传SDK + 视频上传完成 + 保存成功
public/index.html
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="stylesheet" href="/main.css">
+ <script src="/aliyun-upload-sdk-1.5.0/lib/es6-promise.min.js"></script>
+ <script src="/aliyun-upload-sdk-1.5.0/lib/aliyun-oss-sdk-5.3.1.min.js"></script>
+ <script src="/aliyun-upload-sdk-1.5.0/aliyun-upload-sdk-1.5.0.min.js"></script>
+ <link rel="stylesheet" href="https://g.alicdn.com/de/prismplayer/2.9.3/skins/default/aliplayer-min.css" />
+ <script src="https://g.alicdn.com/de/prismplayer/2.9.3/aliplayer-h5-min.js"></script>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
component/UploadViode/index.vue
<template>
<div class="sc-AxiKw dZbDOR">
<div class="modal-content">
<form @submit.prevent="handleSubmit">
<div class="modal-header">
<div class="modal-header-left">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
@click="handleClose"
>
<g>
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
></path>
</g>
</svg>
<h3>Upload Video</h3>
</div>
<div style="display: block">
<button class="sc-AxirZ erzyjX">Save</button>
</div>
</div>
<div class="tab video-form">
<input ref="file" required type="file" @change="handleFileChange" />
<video ref="videoEl" controls></video>
<input v-model="video.title" required type="text" placeholder="Enter the title" />
<textarea
v-model="video.description"
required
placeholder="Tell viewers about your video"
></textarea>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, ref } from 'vue'
import { createUploadVideo, refreshUploadVideo } from '@/api/vod'
import { createVideo } from '@/api/video'
import router from '@/router'
import { useRouter } from 'vue-router'
export default defineComponent({
name: 'UploadVideo',
setup (props, context) {
const router = useRouter()
// 上传的video
const file = ref(null)
// 预览的video
const videoEl = ref(null)
const video = reactive({
title: '',
description: '',
vodVideoId: ''
})
// 关闭弹出框
const handleClose = () => {
// 对外发布一个自定义事件
context.emit('close')
}
// 上传文件
const handleFileChange = () => {
const fileObj = (file.value as any).files[0];
// (videoEl.value as any): videoEl.value转换为any类型
(videoEl.value as any).src = URL.createObjectURL(fileObj)
}
// 创建上传的实例
const createUploader = () => {
const uploader = new window.AliyunUpload.Vod({
// 阿里账号ID,必须有值
userId: '122',
// 分片大小默认1 MB,不能小于100 KB
partSize: 1048576,
// 并行上传分片个数,默认5
parallel: 5,
// 网络原因失败时,重新上传次数,默认为3
retryCount: 3,
// 网络原因失败时,重新上传间隔时间,默认为2秒
retryDuration: 2,
// 是否上报上传日志到视频点播,默认为true
enableUploadProgress: true,
// 开始上传
onUploadstarted: async function (uploadInfo: any) {
console.log('onUploadstarted', uploadInfo)
// 上传方式1,需要根据uploadInfo.videoId是否有值,调用视频点播的不同接口获取uploadauth和uploadAddress,如果videoId有值,调用刷新视频上传凭证接口,否则调用创建视频上传凭证接口
if (uploadInfo.videoId) {
// 如果uploadInfo.videoId存在,调用刷新视频上传凭证接口
const { data } = await refreshUploadVideo(uploadInfo.videoId)
uploader.setUploadAuthAndAddress(uploadInfo, data.UploadAuth, data.UploadAddress, data.VideoId)
} else {
// 如果uploadInfo.videoId不存在,调用获取视频上传地址和凭证接口
// 从视频点播服务获取的uploadAuth、uploadAddress和videoId,设置到SDK里
// uploader.setUploadAuthAndAddress(uploadInfo, uploadAuth, uploadAddress,videoId);
const { data } = await createUploadVideo({
Title: uploadInfo.file.name,
FileName: uploadInfo.file.name
})
uploader.setUploadAuthAndAddress(uploadInfo, data.UploadAuth, data.UploadAddress, data.VideoId)
}
},
// 文件上传成功
onUploadSucceed: async function (uploadInfo: any) {
console.log('onUploadSucceed', uploadInfo)
video.vodVideoId = uploadInfo.videoId
// 提交给后台保存数据
const { data } = await createVideo(video)
console.log('保存成功', data)
router.push({
name: 'watch',
params: {
videoId: data.video._id
}
})
context.emit('close')
},
// 文件上传失败
onUploadFailed: function (uploadInfo: any, code: any, message: any) {
console.log('onUploadFailed', uploadInfo, code, message)
},
// 文件上传进度,单位:字节
onUploadProgress: function (uploadInfo: any, totalSize: any, loadedPercent: any) {
console.log('onUploadProgress', `${Math.ceil(loadedPercent * 100)}%`)
},
// 上传凭证超时
onUploadTokenExpired: async function (uploadInfo: any) {
// console.log('onUploadTokenExpired', uploadInfo)
// 实现时,根据uploadInfo.videoId调用刷新视频上传凭证接口重新获取UploadAuth
// 从点播服务刷新的uploadAuth,设置到SDK里
const { data } = await refreshUploadVideo(uploadInfo.videoId)
uploader.resumeUploadWithAuth(data.UploadAuth)
},
// 全部文件上传结束
onUploadEnd: function (uploadInfo: any) {
console.log('onUploadEnd', uploadInfo)
}
})
return uploader
}
// 提交
const handleSubmit = async () => {
// 获取 uploader 上传实例
const uploader = createUploader()
// 添加上传文件
const paramData = JSON.stringify({ Vod: {} })
uploader.addFile((file.value as any).files[0], null, null, null, paramData)
// 开始上传
uploader.startUpload()
// 上传完成 -> 创建视频
}
return {
handleClose,
file,
videoEl,
handleFileChange,
handleSubmit,
video
}
}
})
</script>
api/video.ts
import { request } from '@/utils/request'
interface CreateVideoInput {
title: string
description: string
vodVideoId: string
}
interface VideoAuth {
_id: string
username: string
avatar: string
isSubscribed: boolean
subscribersCount: number
}
export interface Video {
_id: string
title: string
description: string
vodVideoId: string
commentsCount: number
createdAt: string
dislikesCount: number
likesCount: number
isLiked: boolean
isDisliked: boolean
viewsCount: number
user: VideoAuth
}
interface VideoPayload {
video: Video
}
export const createVideo = (data: CreateVideoInput) => {
return request.post<VideoPayload>('/api/v1/videos', data)
}
export const getVideo = (videoId: string) => {
return request.get<VideoPayload>(`/api/v1/videos/${videoId}`)
}
api/vod.ts
import { request } from '@/utils/request'
interface CreateUploadVideoParams {
Title: string
FileName: string
}
interface CreateUploadVideoPayload {
RequestId: string
VideoId: string
UploadAddress: string
UploadAuth: string
}
export const createUploadVideo = (params: CreateUploadVideoParams) => {
return request.get<CreateUploadVideoPayload>('/api/v1/vod/CreateUploadVideo', {
params
})
}
export const refreshUploadVideo = (videoId: string) => {
return request.get<CreateUploadVideoPayload>('/api/v1/vod/RefreshUploadVideo', {
params: {
videoId
}
})
}
interface VideoMeta {
CoverURL: string
Duration: number
Status: string
Title: string
VideoId: string
}
export interface VideoPlayAuthPayload {
RequestId: string
PlayAuth: string
VideoMeta: VideoMeta
}
export const getVideoPlayAuth = (vodVideoId: string) => {
return request.get<VideoPlayAuthPayload>('/api/v1/vod/GetVideoPlayAuth', {
params: {
VideoId: vodVideoId
}
})
}