Commit 78d2d837 by lijiabin

【需求 21096】 feat: 完成第一版视频奖励碎片亚币等

parent c46ff158
<template>
<Teleport to="body">
<Transition name="reward-toast">
<div
v-if="show"
class="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none"
>
<div class="relative flex flex-col items-center gap-4">
<!-- 光晕背景 -->
<div class="absolute inset--20 rounded-full bg-amber-400/10 blur-3xl animate-pulse" />
<!-- 碎片 +1 -->
<div class="reward-item reward-item-1">
<div
class="flex items-center gap-2.5 px-6 py-3 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 shadow-[0_4px_24px_rgba(99,102,241,0.5)]"
>
<span class="text-2xl">🧩</span>
<span class="text-white text-lg font-bold tracking-wide">{{ rewardText }} + 1</span>
</div>
</div>
<!-- 亚币 +2 -->
<div class="reward-item reward-item-2">
<div
class="flex items-center gap-2.5 px-6 py-3 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 shadow-[0_4px_24px_rgba(245,158,11,0.5)]"
>
<span class="text-2xl">💰</span>
<span class="text-white text-lg font-bold tracking-wide">亚币 + 5</span>
</div>
</div>
<!-- 星星粒子效果 -->
<div class="absolute inset--10">
<span v-for="i in 8" :key="i" class="particle" :style="getParticleStyle(i)" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { SpecificVideoRewardEnum } from '@/constants'
const { rewardVideoType } = defineProps<{
rewardVideoType: SpecificVideoRewardEnum
}>()
const rewardText = computed(() => {
switch (rewardVideoType) {
case SpecificVideoRewardEnum.QA_CHIP:
return 'QA碎片'
case SpecificVideoRewardEnum.ORIGINAL_CHIP:
return '初心碎片'
case SpecificVideoRewardEnum.MODEST_CHIP:
return '本分&包容碎片'
case SpecificVideoRewardEnum.INNOVATION_CHIP:
return '创新碎片'
}
})
const show = ref(false)
const getParticleStyle = (index: number) => {
const angle = (index / 8) * 360
const delay = index * 0.1
return {
'--angle': `${angle}deg`,
'--delay': `${delay}s`,
}
}
const showRewardToast = async () => {
show.value = true
await new Promise((resolve) => setTimeout(resolve, 2500))
show.value = false
await new Promise((resolve) => setTimeout(resolve, 3000))
}
defineExpose({
showRewardToast,
})
</script>
<style scoped>
.reward-toast-enter-active {
transition: opacity 0.3s ease;
}
.reward-toast-leave-active {
transition: opacity 0.5s ease 1.8s;
}
.reward-toast-enter-from,
.reward-toast-leave-to {
opacity: 0;
}
.reward-item {
opacity: 0;
animation: rewardSlideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.reward-item-1 {
animation-delay: 0.1s;
}
.reward-item-2 {
animation-delay: 0.35s;
}
@keyframes rewardSlideUp {
0% {
opacity: 0;
transform: translateY(30px) scale(0.7);
}
60% {
opacity: 1;
transform: translateY(-8px) scale(1.05);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.particle {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #a78bfa);
opacity: 0;
animation: particleBurst 1.2s ease-out var(--delay) forwards;
}
@keyframes particleBurst {
0% {
opacity: 1;
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(0) scale(1);
}
70% {
opacity: 0.8;
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(80px) scale(0.8);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(120px) scale(0);
}
}
</style>
...@@ -90,6 +90,7 @@ ...@@ -90,6 +90,7 @@
</div> </div>
<!-- 视频 16/ 9 --> <!-- 视频 16/ 9 -->
<!-- 监听每秒播放进度 -->
<div class="mx-4"> <div class="mx-4">
<video <video
ref="videoRef" ref="videoRef"
...@@ -98,6 +99,10 @@ ...@@ -98,6 +99,10 @@
controls controls
@play="handlePlay" @play="handlePlay"
@pause="handlePause" @pause="handlePause"
@timeupdate="handleTimeUpdate"
@seeking="handleSeeking"
@seeked="handleSeeked"
@loadedmetadata="handleLoadedMetadata"
></video> ></video>
</div> </div>
<div class="bg-white rounded-xl shadow-sm p-4"> <div class="bg-white rounded-xl shadow-sm p-4">
...@@ -305,27 +310,36 @@ ...@@ -305,27 +310,36 @@
class="mt-5" class="mt-5"
/> />
<RewardDialog ref="rewardDialogRef" v-model:rewardNum="videoDetail.rewardNum" /> <RewardDialog ref="rewardDialogRef" v-model:rewardNum="videoDetail.rewardNum" />
<RewardToast ref="rewardToastRef" :rewardVideoType="rewardVideoType" />
<RewardFullSetToast ref="rewardFullSetToastRef" @hided="videoRef?.play()" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
getArticleDetail, getArticleDetail,
addOrCanceArticlelCollect, addOrCanceArticlelCollect,
addOrCanceArticlelLike, addOrCanceArticlelLike,
addVideoPlayCount, addVideoPlayCount,
getSpecificVideoWatchReward,
} from '@/api' } from '@/api'
import type { ArticleItemDto } from '@/api/article/types' import type { ArticleItemDto } from '@/api/article/types'
import Comment from '@/components/common/Comment/index.vue' import Comment from '@/components/common/Comment/index.vue'
import RewardDialog from './components/rewardDialog.vue' import RewardDialog from './components/rewardDialog.vue'
import RewardToast from './components/rewardToast.vue'
import RewardFullSetToast from './components/rewardFullSetToast.vue'
import ActionMore from '@/components/common/ActionMore/index.vue' import ActionMore from '@/components/common/ActionMore/index.vue'
import BackButton from '@/components/common/BackButton/index.vue' import BackButton from '@/components/common/BackButton/index.vue'
import { useNavigation } from '@/hooks' import { useNavigation } from '@/hooks'
import { ArticleTypeEnum } from '@/constants' import {
ArticleTypeEnum,
SpecificVideoRewardEnum,
specificVideoRewardListOptions,
} from '@/constants'
import { push } from 'notivue' import { push } from 'notivue'
import { useStorage } from '@vueuse/core'
const route = useRoute() const route = useRoute()
const videoId = Number(route.params.id) const videoId = Number(route.params.id)
const { jumpToUserHomePage } = useNavigation() const { jumpToUserHomePage } = useNavigation()
...@@ -333,8 +347,126 @@ const { jumpToUserHomePage } = useNavigation() ...@@ -333,8 +347,126 @@ const { jumpToUserHomePage } = useNavigation()
const videoDetail = ref({} as ArticleItemDto) const videoDetail = ref({} as ArticleItemDto)
const loading = computed(() => !videoDetail.value.title) const loading = computed(() => !videoDetail.value.title)
const videoRef = useTemplateRef<HTMLVideoElement | null>('videoRef')
const rewardFullSetToastRef = useTemplateRef<InstanceType<typeof RewardFullSetToast> | null>(
'rewardFullSetToastRef',
)
const commentRef = useTemplateRef<InstanceType<typeof Comment> | null>('commentRef') const commentRef = useTemplateRef<InstanceType<typeof Comment> | null>('commentRef')
// 关于视频跳出奖励相关的逻辑
const watchedSecondsObj = useStorage('watched-seconds-obj', {
[SpecificVideoRewardEnum.QA_CHIP]: {
watchedSeconds: 0,
hasReward: false,
},
[SpecificVideoRewardEnum.ORIGINAL_CHIP]: {
watchedSeconds: 0,
hasReward: false,
},
[SpecificVideoRewardEnum.MODEST_CHIP]: {
watchedSeconds: 0,
hasReward: false,
},
[SpecificVideoRewardEnum.INNOVATION_CHIP]: {
watchedSeconds: 0,
hasReward: false,
},
})
const rewardDialogRef = useTemplateRef<InstanceType<typeof RewardDialog> | null>('rewardDialogRef') const rewardDialogRef = useTemplateRef<InstanceType<typeof RewardDialog> | null>('rewardDialogRef')
const rewardVideoLimitDuration = ref(0)
const specificVideoRewardItem = computed(() => {
return specificVideoRewardListOptions.find((item) =>
videoDetail.value.title?.includes(item.title),
)
})
const showRewardToastComp = computed(() => {
return !!specificVideoRewardItem.value
})
const rewardVideoType = computed(() => {
return specificVideoRewardItem.value?.value ?? SpecificVideoRewardEnum.QA_CHIP
})
const rewardToastRef = useTemplateRef<InstanceType<typeof RewardToast> | null>('rewardToastRef')
// 真实观看时长(只统计连续播放,不算暂停和拖拽跳跃)
const isPlaying = ref(false)
const isSeeking = ref(false)
let lastVideoTime: number | null = null
const resetWatchCursor = (t: number) => {
lastVideoTime = Number.isFinite(t) ? t : null
}
const accumulateWatchTime = async (video: HTMLVideoElement) => {
if (!isPlaying.value || isSeeking.value) return
const t = video.currentTime
if (!Number.isFinite(t)) return
if (lastVideoTime == null) {
resetWatchCursor(t)
return
}
const delta = t - lastVideoTime
resetWatchCursor(t)
// 只统计“自然播放”的连续前进:拖拽/跳跃(大步长)或倒退都不算
// timeupdate 通常 250ms~1s 一次,这里给一点冗余避免误伤
if (delta > 0 && delta <= 1.5) {
watchedSecondsObj.value[rewardVideoType.value].watchedSeconds += delta
if (
watchedSecondsObj.value[rewardVideoType.value].watchedSeconds >=
rewardVideoLimitDuration.value &&
!watchedSecondsObj.value[rewardVideoType.value].hasReward
) {
// 调用后端接口
await getSpecificVideoWatchReward(rewardVideoType.value)
const rewardToastPromise = rewardToastRef.value?.showRewardToast()
watchedSecondsObj.value[rewardVideoType.value].hasReward = true
// 如果四个都奖励了,则显示全套奖励toast
if (Object.values(watchedSecondsObj.value).every((item) => item.hasReward)) {
// 暂停视频
await rewardToastPromise
videoRef.value?.pause()
rewardFullSetToastRef.value?.showFullSetToast()
watchedSecondsObj.value = null
}
}
}
}
const handleTimeUpdate = (event: Event) => {
if (showRewardToastComp.value) {
const video = event.target as HTMLVideoElement
accumulateWatchTime(video)
}
}
const handleSeeking = (event: Event) => {
if (showRewardToastComp.value) {
const video = event.target as HTMLVideoElement
isSeeking.value = true
resetWatchCursor(video.currentTime)
}
}
const handleSeeked = (event: Event) => {
if (showRewardToastComp.value) {
const video = event.target as HTMLVideoElement
isSeeking.value = false
resetWatchCursor(video.currentTime)
}
}
const handleLoadedMetadata = () => {
if (videoRef.value && showRewardToastComp.value) {
// 四舍五入
rewardVideoLimitDuration.value = Math.round(videoRef.value.duration / 2)
}
}
// 格式化数字 // 格式化数字
const formatNumber = (num: number) => { const formatNumber = (num: number) => {
...@@ -347,12 +479,21 @@ const formatNumber = (num: number) => { ...@@ -347,12 +479,21 @@ const formatNumber = (num: number) => {
// 播放 记录播放量 + 1 // 播放 记录播放量 + 1
const handlePlay = async () => { const handlePlay = async () => {
if (showRewardToastComp.value) {
isPlaying.value = true
isSeeking.value = false
}
resetWatchCursor(videoRef.value?.currentTime ?? 0)
await addVideoPlayCount(videoDetail.value.id) await addVideoPlayCount(videoDetail.value.id)
videoDetail.value.playCount = videoDetail.value.playCount + 1 videoDetail.value.playCount = videoDetail.value.playCount + 1
} }
const handlePause = () => { const handlePause = () => {
// 记录暂停 if (showRewardToastComp.value) {
isPlaying.value = false
resetWatchCursor(videoRef.value?.currentTime ?? 0)
}
} }
// 点赞 // 点赞
...@@ -375,10 +516,8 @@ const handleCollect = async (item: ArticleItemDto) => { ...@@ -375,10 +516,8 @@ const handleCollect = async (item: ArticleItemDto) => {
const handleReward = () => { const handleReward = () => {
rewardDialogRef.value?.open(videoDetail.value.id) rewardDialogRef.value?.open(videoDetail.value.id)
} }
onMounted(async () => { onMounted(async () => {
const { data } = await getArticleDetail(videoId) const { data } = await getArticleDetail(videoId)
console.log(data)
videoDetail.value = data videoDetail.value = data
}) })
</script> </script>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment