Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
C
corporate-culture-qd
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
王立鹏
corporate-culture-qd
Commits
78d2d837
Commit
78d2d837
authored
Mar 19, 2026
by
lijiabin
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
【需求 21096】 feat: 完成第一版视频奖励碎片亚币等
parent
c46ff158
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
1052 additions
and
6 deletions
+1052
-6
rewardFullSetToast.vue
src/views/videoDetail/components/rewardFullSetToast.vue
+761
-0
rewardToast.vue
src/views/videoDetail/components/rewardToast.vue
+146
-0
index.vue
src/views/videoDetail/index.vue
+145
-6
No files found.
src/views/videoDetail/components/rewardFullSetToast.vue
0 → 100644
View file @
78d2d837
<
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
>
src/views/videoDetail/components/rewardToast.vue
0 → 100644
View file @
78d2d837
<
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
>
src/views/videoDetail/index.vue
View file @
78d2d837
...
@@ -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
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment