Commit 78d2d837 by lijiabin

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

parent c46ff158
<template>
<Teleport to="body">
<Transition name="fullset-toast">
<div v-if="show" class="fullset-mask">
<div class="fullset-wrap">
<!-- 背景光晕 + 径向爆闪 -->
<div class="halo" />
<div class="stage" aria-hidden="true" />
<div class="spotlight" aria-hidden="true" />
<div class="burst" aria-hidden="true">
<span v-for="i in 12" :key="i" class="ray" :style="getRayStyle(i)" />
</div>
<!-- 4 碎片:从四角飞入 + 环绕合成(带名称 & 配色) -->
<div
v-for="chip in chips"
:key="chip.key"
class="chip-badge"
:class="chip.badgeClass"
aria-hidden="true"
>
<span class="chip-emoji">🧩</span>
<span class="chip-name">{{ chip.name }}</span>
</div>
<!-- 合成闪光:碎片合成瞬间的光斑 -->
<div class="merge-flash" aria-hidden="true" />
<!-- 合成后的奖励:居中竖排"成就解锁"风格 -->
<div class="reward-card">
<!-- 顶部光环装饰 -->
<div class="card-glow" aria-hidden="true" />
<!-- 大金币 -->
<div class="coin-ring">
<div class="coin" aria-hidden="true">💰</div>
</div>
<!-- 标签 -->
<div class="card-label">成就达成</div>
<!-- 主标题 -->
<div class="card-title">集齐四碎片</div>
<!-- 分隔线 -->
<div class="card-divider" />
<!-- 奖励数值 -->
<div class="card-reward">
<span class="card-reward-prefix">额外奖励</span>
<span class="card-reward-value">+10 <span class="card-reward-unit">YA币</span></span>
</div>
</div>
<!-- 金币雨:与原 toast 的粒子不同,偏“下落庆祝” -->
<div class="coin-rain" aria-hidden="true">
<span v-for="i in 14" :key="i" class="coin-drop" :style="getDropStyle(i)">💰</span>
</div>
<!-- 彩纸:更“庆祝”一点的落下效果 -->
<div class="confetti" aria-hidden="true">
<span v-for="i in 18" :key="i" class="confetti-piece" :style="getConfettiStyle(i)" />
</div>
</div>
<!-- 播放结束后显示:继续观看 -->
<div class="continue-zone">
<button
type="button"
class="continue-btn cursor-pointer"
:class="{ 'continue-btn--show': canContinue }"
:disabled="!canContinue"
@click="hide()"
>
继续观看
</button>
<div class="hint" :class="{ 'hint--show': canContinue }">已获得奖励,可继续观看</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const emit = defineEmits<{
hided: []
}>()
const show = ref(false)
const canContinue = ref(false)
let timerId: number | undefined
const chips = [
{ key: 'qa', name: 'QA碎片', badgeClass: 'chip-pos-1 chip-qa' },
{ key: 'origin', name: '初心碎片', badgeClass: 'chip-pos-2 chip-origin' },
{ key: 'modest', name: '本分&包容碎片', badgeClass: 'chip-pos-3 chip-modest' },
{ key: 'innov', name: '创新碎片', badgeClass: 'chip-pos-4 chip-innov' },
] as const
const getRayStyle = (i: number) => {
const angle = (i / 12) * 360
const delay = 0.55 + i * 0.015
return {
'--a': `${angle}deg`,
'--d': `${delay}s`,
}
}
const getDropStyle = (i: number) => {
const left = (i / 14) * 100
const delay = 0.7 + i * 0.06
const drift = (i % 2 === 0 ? 1 : -1) * (6 + (i % 5))
return {
left: `${left}%`,
'--delay': `${delay}s`,
'--drift': `${drift}px`,
}
}
const getConfettiStyle = (i: number) => {
const left = (i / 18) * 100
const delay = 0.6 + i * 0.045
const rot = (i * 37) % 360
const drift = (i % 2 === 0 ? 1 : -1) * (18 + (i % 6) * 3)
return {
left: `${left}%`,
'--delay': `${delay}s`,
'--rot': `${rot}deg`,
'--drift': `${drift}px`,
}
}
const clearTimer = () => {
if (timerId != null) {
window.clearTimeout(timerId)
timerId = undefined
}
}
const hide = () => {
clearTimer()
show.value = false
canContinue.value = false
emit('hided')
}
const showFullSetToast = () => {
clearTimer()
show.value = true
canContinue.value = false
// 动画基本播放完再允许继续
timerId = window.setTimeout(() => {
canContinue.value = true
}, 3400)
}
defineExpose({
showFullSetToast,
hide,
})
</script>
<style scoped>
.fullset-mask {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 18px;
background:
radial-gradient(circle at 50% 45%, rgba(2, 6, 23, 0.25), rgba(2, 6, 23, 0.82) 65%),
linear-gradient(180deg, rgba(2, 6, 23, 0.75), rgba(2, 6, 23, 0.92));
backdrop-filter: blur(2px);
}
.fullset-wrap {
position: relative;
width: min(920px, 96vw);
height: 440px;
display: grid;
place-items: center;
pointer-events: none;
}
.halo {
position: absolute;
inset: -56px;
border-radius: 999px;
background:
radial-gradient(circle at 25% 35%, rgba(59, 130, 246, 0.3), transparent 58%),
radial-gradient(circle at 70% 30%, rgba(168, 85, 247, 0.28), transparent 60%),
radial-gradient(circle at 55% 80%, rgba(245, 158, 11, 0.22), transparent 60%),
radial-gradient(circle at 45% 50%, rgba(255, 255, 255, 0.1), transparent 55%);
filter: blur(26px);
animation: haloPulse 1.2s ease-in-out infinite;
}
.stage {
position: absolute;
width: min(920px, 96vw);
height: 440px;
border-radius: 30px;
background: linear-gradient(
180deg,
rgba(2, 6, 23, 0),
rgba(2, 6, 23, 0.06) 35%,
rgba(2, 6, 23, 0.12)
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 34px 120px rgba(2, 6, 23, 0.25);
backdrop-filter: blur(8px);
}
.spotlight {
position: absolute;
inset: -10px;
border-radius: 32px;
background:
radial-gradient(closest-side at 50% 8%, rgba(255, 255, 255, 0.16), transparent 62%),
radial-gradient(closest-side at 30% 12%, rgba(59, 130, 246, 0.18), transparent 60%),
radial-gradient(closest-side at 70% 14%, rgba(168, 85, 247, 0.18), transparent 60%);
filter: blur(10px);
opacity: 0;
animation: spotlightIn 0.55s ease-out 0.15s forwards;
}
.burst {
position: absolute;
inset: 0;
pointer-events: none;
}
.ray {
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 92px;
border-radius: 2px;
background: linear-gradient(to bottom, rgba(251, 191, 36, 0), rgba(251, 191, 36, 0.95));
transform-origin: 50% 100%;
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(-118px) scaleY(0.6);
animation: rayPop 1.05s ease-out var(--d) forwards;
}
.chip-badge {
position: absolute;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(15, 23, 42, 0.38);
backdrop-filter: blur(10px);
box-shadow:
0 18px 70px rgba(2, 6, 23, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
opacity: 0;
transform: translate(0, 0) scale(0.9);
}
.chip-emoji {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
font-size: 20px;
}
.chip-name {
font-size: 16px;
font-weight: 800;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
}
.chip-qa .chip-emoji {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.55), rgba(99, 102, 241, 0.2));
}
.chip-origin .chip-emoji {
background: linear-gradient(135deg, rgba(236, 72, 153, 0.55), rgba(168, 85, 247, 0.2));
}
.chip-modest .chip-emoji {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.55), rgba(34, 211, 238, 0.2));
}
.chip-innov .chip-emoji {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.6), rgba(250, 204, 21, 0.18));
}
.chip-pos-1 {
animation:
badgeIn1 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards,
badgeOrbit 1.25s ease-in-out 0.9s forwards;
}
.chip-pos-2 {
animation:
badgeIn2 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards,
badgeOrbit 1.25s ease-in-out 0.9s forwards;
}
.chip-pos-3 {
animation:
badgeIn3 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards,
badgeOrbit 1.25s ease-in-out 0.9s forwards;
}
.chip-pos-4 {
animation:
badgeIn4 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards,
badgeOrbit 1.25s ease-in-out 0.9s forwards;
}
.merge-flash {
position: absolute;
width: 300px;
height: 300px;
border-radius: 999px;
background: radial-gradient(
circle,
rgba(251, 191, 36, 0.45),
rgba(251, 191, 36, 0.08) 45%,
transparent 70%
);
filter: blur(10px);
opacity: 0;
transform: scale(0.7);
animation: mergeFlash 0.85s ease-out 1.85s forwards;
}
.reward-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 36px 52px 30px;
border-radius: 28px;
background: linear-gradient(
170deg,
rgba(30, 58, 138, 0.94) 0%,
rgba(88, 28, 135, 0.94) 55%,
rgba(124, 58, 237, 0.9) 100%
);
box-shadow:
0 30px 140px rgba(2, 6, 23, 0.35),
0 20px 80px rgba(59, 130, 246, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.2);
transform: translateY(28px) scale(0.9);
opacity: 0;
animation: cardRise 0.9s cubic-bezier(0.22, 1, 0.36, 1) 2.05s forwards;
backdrop-filter: blur(12px);
overflow: hidden;
}
.card-glow {
position: absolute;
top: -60px;
left: 50%;
transform: translateX(-50%);
width: 280px;
height: 120px;
border-radius: 999px;
background: radial-gradient(ellipse, rgba(251, 191, 36, 0.3), transparent 70%);
filter: blur(18px);
pointer-events: none;
}
.coin-ring {
position: relative;
width: 90px;
height: 90px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.08));
border: 2px solid rgba(251, 191, 36, 0.3);
display: grid;
place-items: center;
margin-bottom: 4px;
box-shadow:
0 0 0 6px rgba(251, 191, 36, 0.08),
0 0 0 12px rgba(251, 191, 36, 0.04);
animation: ringPulse 2s ease-in-out 2.6s infinite;
}
.coin {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 999px;
background:
radial-gradient(
circle at 35% 30%,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.15) 35%,
rgba(251, 191, 36, 0) 70%
),
linear-gradient(135deg, #fde68a, #f59e0b);
box-shadow: 0 14px 52px rgba(245, 158, 11, 0.44);
font-size: 30px;
transform: rotate(-10deg) scale(0.86);
animation: coinPop 1s cubic-bezier(0.34, 1.56, 0.64, 1) 2.25s both;
}
.card-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(251, 191, 36, 0.85);
background: linear-gradient(90deg, rgba(251, 191, 36, 0.15), rgba(251, 191, 36, 0.06));
padding: 3px 14px;
border-radius: 999px;
margin-bottom: 2px;
}
.card-title {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.04em;
color: #fff;
text-shadow: 0 4px 22px rgba(2, 6, 23, 0.35);
}
.card-divider {
width: 60px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.55), transparent);
margin: 4px 0;
}
.card-reward {
display: flex;
align-items: baseline;
gap: 6px;
}
.card-reward-prefix {
font-size: 14px;
color: rgba(226, 232, 240, 0.8);
font-weight: 500;
}
.card-reward-value {
font-size: 32px;
font-weight: 900;
color: #fef3c7;
text-shadow: 0 8px 28px rgba(245, 158, 11, 0.5);
line-height: 1;
}
.card-reward-unit {
font-size: 18px;
font-weight: 800;
color: #fde68a;
}
.coin-rain {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.coin-drop {
position: absolute;
top: -22px;
font-size: 20px;
opacity: 0;
filter: drop-shadow(0 10px 18px rgba(245, 158, 11, 0.35));
animation: coinDrop 1.7s ease-out var(--delay) forwards;
}
.confetti {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.confetti-piece {
position: absolute;
top: -18px;
width: 10px;
height: 16px;
border-radius: 3px;
opacity: 0;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.95), rgba(251, 191, 36, 0.85));
box-shadow: 0 10px 22px rgba(2, 6, 23, 0.25);
animation: confettiDrop 1.9s ease-out var(--delay) forwards;
}
.continue-zone {
width: min(920px, 96vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding-bottom: 10px;
}
.continue-btn {
pointer-events: auto;
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.22);
background: linear-gradient(135deg, rgba(251, 191, 36, 0.95), rgba(245, 158, 11, 0.92));
color: rgba(17, 24, 39, 0.95);
font-weight: 900;
letter-spacing: 0.03em;
border-radius: 999px;
padding: 14px 28px;
min-width: 190px;
box-shadow:
0 22px 80px rgba(245, 158, 11, 0.28),
0 10px 26px rgba(2, 6, 23, 0.35);
transform: translateY(10px) scale(0.96);
opacity: 0;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
}
.continue-btn--show {
opacity: 1;
transform: translateY(0) scale(1);
animation: btnPop 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.continue-btn:disabled {
cursor: not-allowed;
filter: grayscale(0.35) brightness(0.85);
box-shadow: 0 14px 40px rgba(2, 6, 23, 0.35);
}
.continue-btn:not(:disabled):hover {
filter: brightness(1.05);
box-shadow:
0 28px 100px rgba(245, 158, 11, 0.34),
0 16px 34px rgba(2, 6, 23, 0.35);
transform: translateY(-1px) scale(1.02);
}
.continue-btn:not(:disabled):active {
transform: translateY(0) scale(0.98);
}
.hint {
pointer-events: none;
color: rgba(226, 232, 240, 0.78);
font-size: 12px;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.hint--show {
opacity: 1;
transform: translateY(0);
}
/* Transition */
.fullset-toast-enter-active {
transition: opacity 0.22s ease;
}
.fullset-toast-leave-active {
transition: opacity 0.28s ease;
}
.fullset-toast-enter-from,
.fullset-toast-leave-to {
opacity: 0;
}
@keyframes haloPulse {
0%,
100% {
transform: scale(0.98);
opacity: 0.9;
}
50% {
transform: scale(1.03);
opacity: 1;
}
}
@keyframes spotlightIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes rayPop {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(-78px) scaleY(0.2);
}
45% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(-110px) scaleY(1);
}
}
@keyframes badgeIn1 {
from {
opacity: 0;
transform: translate(-340px, -200px) scale(0.55) rotate(-14deg);
}
to {
opacity: 1;
transform: translate(-90px, -64px) scale(1) rotate(4deg);
}
}
@keyframes badgeIn2 {
from {
opacity: 0;
transform: translate(340px, -200px) scale(0.55) rotate(14deg);
}
to {
opacity: 1;
transform: translate(90px, -64px) scale(1) rotate(-4deg);
}
}
@keyframes badgeIn3 {
from {
opacity: 0;
transform: translate(-340px, 200px) scale(0.55) rotate(14deg);
}
to {
opacity: 1;
transform: translate(-90px, 64px) scale(1) rotate(-4deg);
}
}
@keyframes badgeIn4 {
from {
opacity: 0;
transform: translate(340px, 200px) scale(0.55) rotate(-14deg);
}
to {
opacity: 1;
transform: translate(90px, 64px) scale(1) rotate(4deg);
}
}
/* 环绕后合成消失(缩到中心) */
@keyframes badgeOrbit {
0% {
filter: drop-shadow(0 18px 48px rgba(59, 130, 246, 0.35));
}
55% {
transform: translate(0, 0) scale(0.98) rotate(10deg);
filter: drop-shadow(0 18px 52px rgba(245, 158, 11, 0.35));
}
100% {
opacity: 0;
transform: translate(0, 0) scale(0.12) rotate(20deg);
filter: blur(1px);
}
}
@keyframes mergeFlash {
0% {
opacity: 0;
transform: scale(0.7);
}
35% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0;
transform: scale(1.25);
}
}
@keyframes cardRise {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ringPulse {
0%,
100% {
box-shadow:
0 0 0 6px rgba(251, 191, 36, 0.08),
0 0 0 12px rgba(251, 191, 36, 0.04);
}
50% {
box-shadow:
0 0 0 8px rgba(251, 191, 36, 0.14),
0 0 0 16px rgba(251, 191, 36, 0.06);
}
}
@keyframes coinPop {
0% {
transform: rotate(-10deg) scale(0.7);
filter: brightness(1);
}
55% {
transform: rotate(6deg) scale(1.14);
filter: brightness(1.12);
}
100% {
transform: rotate(0deg) scale(1);
filter: brightness(1);
}
}
@keyframes coinDrop {
0% {
opacity: 0;
transform: translateX(0) translateY(0) rotate(0deg) scale(0.9);
}
10% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(var(--drift)) translateY(420px) rotate(140deg) scale(1);
}
}
@keyframes confettiDrop {
0% {
opacity: 0;
transform: translateY(0) translateX(0) rotate(var(--rot)) scale(0.9);
}
10% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(420px) translateX(var(--drift)) rotate(calc(var(--rot) + 240deg)) scale(1);
}
}
@keyframes btnPop {
0% {
transform: translateY(10px) scale(0.92);
}
60% {
transform: translateY(-2px) scale(1.03);
}
100% {
transform: translateY(0) scale(1);
}
}
</style>
<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