Files
chatroom/resources/views/chat/partials/games/earn-panel.blade.php
lkddi 97e32572cf feat: 新增看视频赚金币功能
- 在右侧导航新增「赚钱」入口(娱乐下方)
- 新增 earn-panel 弹窗:风格与商店一致,800px 宽度
- 集成 FluidPlayer + VAST 广告(ExoClick)
- 动态倒计时:实时监听视频 duration/currentTime
- VAST 失败时自动回退保底视频,20s 超时保底放行
- 修复 AbortError:idle 时 video 不预播放,仅提供 fallback source
- 删除不支持的 player.on('error') 调用
- 所有 overlay 改用绝对定位居中,修复 Alpine x-show 破坏 flex 问题
- EarnController:Redis 每日 10 次限额 + 冷却防刷
- 领取成功后广播全服系统消息(含金币+经验+快捷入口标签)
- 移除神秘盒子相关 UI 代码
2026-04-02 18:35:54 +08:00

343 lines
18 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能:观看广告赚钱面板
包含 ExoClick 广告展示和倒计时领奖逻辑,基于 Alpine.js
@author ChatRoom Laravel
@version 1.0.0
--}}
<div x-data="earnPanelData()"
@open-earn-panel.window="openPanel()"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
x-show="isOpen"
x-transition.opacity
style="display: none;">
<div style="background-color: #ffffff; border-radius: 8px; width: 800px; max-width: 95vw; max-height: 84vh; display: flex; flex-direction: column; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; position: relative;"
@click.outside="closePanel()">
{{-- 标题栏 与设置/商店/头像弹窗一致 --}}
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding: 8px 16px; color: white; background: linear-gradient(135deg, #336699, #5a8fc0);">
<div style="font-size: 14px; font-weight: bold; flex: 1;">💰 赚钱 - 看视频得金币经验</div>
<button @click="closePanel()" style="background: none; border: none; color: white; cursor: pointer; font-size: 18px; line-height: 1; opacity: 0.8;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.8">&times;</button>
</div>
{{-- 面板内容区域 --}}
<div style="padding: 16px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; align-items: center;">
{{-- 信息说明区域 --}}
<div style="background-color: #f0f6ff; border: 1px solid #cce0f5; border-radius: 4px; padding: 12px; margin-bottom: 16px; width: 100%; text-align: center;">
<div style="color: #336699; font-weight: bold; margin-bottom: 8px; font-size: 14px;">
完整观看视频后,即可获得 <span style="color: #d97706;">5000 金币</span> <span style="color: #16a34a;">500 经验</span> 奖励!<br>
</div>
<div style="font-size: 12px; color: #6b7280;">
中途关闭视作放弃,不会扣除观影次数。每日最多获取 10 次。
</div>
</div>
{{-- 视频广告容器 --}}
<div style="width: 100%; aspect-ratio: 16/9; background-color: #e2e8f0; border: 1px solid #cbd5e1; border-radius: 4px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; margin-bottom: 16px;">
{{-- video 元素常驻 DOMpreload=none 防止空闲时预加载。
必须保留 fallback sourceVAST 失败时 FluidPlayer 会调用 playMainVideoWhenVastFails(),
它会 load() 到此 source source 为空则找不到目标,仍会触发 AbortError。 --}}
<div id="video-wrapper" style="width: 100%; height: 100%; background-color: #000; display: flex; align-items: center; justify-content: center;">
<video id="exoclick-video" style="width: 100%; height: 100%;" playsinline preload="none">
<source src="https://cdn.fluidplayer.com/videos/valerian-1080p.mkv" type="video/mp4" />
</video>
</div>
{{-- Idle Overlay (空闲时覆盖在视频之上) --}}
{{-- 注意:不用 display:flex 居中,因为 Alpine x-show 恢复时只设 display:block 会覆盖 flex --}}
<div x-show="status === 'idle'" style="position: absolute; inset: 0; background-color: #e2e8f0; z-index: 10;">
<button @click="startWatching()" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: linear-gradient(135deg, #336699, #5a8fc0); color: #ffffff; padding: 12px 32px; border-radius: 4px; font-weight: bold; border: none; font-size: 15px; cursor: pointer; white-space: nowrap;">
开始观看
</button>
</div>
{{-- Completed Overlay (完毕覆盖层) --}}
<div x-show="status === 'completed'" style="position: absolute; inset: 0; background-color: #f0fdf4; z-index: 10;">
<span style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #16a34a; font-weight: bold; font-size: 28px; white-space: nowrap;"> 观看完毕!</span>
</div>
{{-- Claiming Overlay (领奖覆盖层) --}}
<div x-show="status === 'claiming' || status === 'claimed'" style="position: absolute; inset: 0; background-color: #fefce8; z-index: 10;">
<div x-show="status === 'claiming'" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #d97706; font-weight: bold; font-size: 16px; white-space: nowrap;">
正在发放奖励...
</div>
<div x-show="status === 'claimed'" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;">
<div style="color: #d97706; font-weight: bold; font-size: 24px;">🎉 奖励已到账</div>
</div>
</div>
{{-- 浮动倒计时器 --}}
<div x-show="status === 'watching'" style="position: absolute; bottom: 8px; right: 8px; background-color: rgba(0,0,0,0.6); color: white; font-family: monospace; padding: 4px 12px; border-radius: 4px; font-size: 12px; z-index: 50;">
进度: <span x-text="countdown"></span>
</div>
</div>
{{-- 底部按钮区域 --}}
<div style="width: 100%;">
<template x-if="status === 'completed'">
<button @click="claimReward()" style="width: 100%; background-color: #16a34a; color: #ffffff; padding: 10px 24px; border-radius: 4px; font-weight: bold; border: none; font-size: 14px; cursor: pointer;">
点击领取奖励
</button>
</template>
<template x-if="status === 'claimed'">
<button @click="resetPanel()" style="width: 100%; background-color: #64748b; color: #ffffff; padding: 10px 24px; border-radius: 4px; font-weight: bold; border: none; font-size: 14px; cursor: pointer;">
关闭面板
</button>
</template>
</div>
</div>
</div>
</div>
<!-- 引入 Fluid Player 用于播放 VAST 广告 -->
<link rel="stylesheet" href="https://cdn.fluidplayer.com/v3/current/fluidplayer.min.css" type="text/css"/>
<script src="https://cdn.fluidplayer.com/v3/current/fluidplayer.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('earnPanelData', () => ({
isOpen: false,
status: 'idle', // idle, watching, completed, claiming, claimed
countdown: '获取中...',
timer: null,
player: null,
videoFinished: false,
openPanel() {
this.isOpen = true;
if (this.status !== 'claimed' && this.status !== 'claiming') {
this.resetPanelState();
}
},
closePanel() {
this.isOpen = false;
this.stopTimer();
if (this.status === 'watching') {
window.chatToast.show({ title: '取消提示', message: '已取消观看,没有扣除观影次数。', icon: '', color: '#3b82f6' });
}
if (this.player) {
try { this.player.destroy(); } catch(e) {}
this.player = null;
}
this.restoreVideoDOM();
this.status = 'idle';
},
resetPanelState() {
this.status = 'idle';
this.countdown = '获取中...';
this.videoFinished = false;
this.stopTimer();
if (this.player) {
try { this.player.destroy(); } catch(e) {}
this.player = null;
}
this.restoreVideoDOM();
},
resetPanel() {
this.closePanel();
setTimeout(() => { this.resetPanelState(); }, 300);
},
restoreVideoDOM() {
// FluidPlayer 销毁后会修改/包裹 video 元素,需要重建以便下次干净初始化
// source 必须保留,否则 VAST 失败时 FluidPlayer 的 fallback 无目标可播
const wrapper = document.getElementById('video-wrapper');
if (wrapper) {
wrapper.innerHTML = `<video id="exoclick-video" style="width: 100%; height: 100%;" playsinline preload="none"><source src="https://cdn.fluidplayer.com/videos/valerian-1080p.mkv" type="video/mp4" /></video>`;
}
},
completeWatch() {
this.videoFinished = true;
this.stopTimer();
this.status = 'completed';
if (this.player) {
try { this.player.destroy(); } catch(e) {}
this.player = null;
}
this.restoreVideoDOM();
if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') {
EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{});
}
},
startWatching() {
this.status = 'watching';
this.countdown = '请求广告...';
this.videoFinished = false;
this.stopTimer();
// 同步初始化播放器,保证满足浏览器的 user gesture 直接挂钩播放
this.initPlayer();
let elapsedTime = 0;
// 基于视频真实时长的倒计时检测与失败降级处理
this.timer = setInterval(() => {
if (this.videoFinished) return;
const v = document.getElementById('exoclick-video');
elapsedTime += 0.5;
if (v) {
const dur = v.duration;
const cur = v.currentTime;
// 当系统取到真实的视频时长时确保不是NaN
if (!isNaN(dur) && dur > 0) {
const left = Math.ceil(dur - cur);
this.countdown = (left > 0 ? left : 0) + ' 秒';
// 若倒计时归零并且正在播放中(保证不是误判)
if (left <= 0 && cur > 0) {
this.completeWatch();
}
} else {
// 没有视频时长(可能正在缓冲,也可能广告没有填充)
// 最多让用户干等 20 秒作为无广告情况下的保底奖励
const fallbackLeft = Math.ceil(20 - elapsedTime);
if (fallbackLeft > 0) {
this.countdown = '缓冲/加载中... ' + fallbackLeft + 's';
} else {
// 20秒超时还没获得时长则当作广告未填充或网速太慢放行奖励
this.completeWatch();
}
}
}
}, 500);
},
initPlayer() {
// 按照 Chrome 规范:确保 video 当前未在 play() 中,通过 pause() 后再初始化
// 参考https://developer.chrome.com/blog/play-request-was-interrupted
const v = document.getElementById('exoclick-video');
const doInit = () => {
try {
this.player = fluidPlayer(
'exoclick-video',
{
layoutControls: {
primaryColor: "#336699",
posterImage: false,
playButtonShowing: true,
playPauseAnimation: false,
fillToContainer: true,
autoPlay: true,
mute: false
},
vastOptions: {
allowVPAID: true,
vastTimeout: 8000,
adList: [
{
roll: 'preRoll',
vastTag: 'https://s.magsrv.com/v1/vast.php?idzone=5889208',
adText: '请观看广告获取金币'
}
]
}
}
);
// 注意FluidPlayer 不支持 player.on('error'),不要注册该事件
// VAST 失败时 FluidPlayer 会自动播放 fallback source
// 我们的 setInterval 轮询 currentTime/duration 会自然触发 completeWatch()
} catch(e) {
console.error('[EarnPanel] 播放器初始化未成功', e);
}
};
if (v && !v.paused) {
// 如果 video 正在播放,先等 pause 后再 init避免 play() 被 load() 中断
v.pause();
v.addEventListener('pause', doInit, { once: true });
} else {
doInit();
}
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
async claimReward() {
if (this.status !== 'completed') return;
this.status = 'claiming';
try {
const payload = {
room_id: window.chatContext?.roomId || 0
};
const response = await fetch(window.chatContext.earnRewardUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok && data.success) {
this.status = 'claimed';
window.chatToast.show({ title: '奖励到账', message: data.message || '领取成功!', icon: '🎉', color: '#10b981' });
// 更新本地上下文
if (data.new_jjb !== undefined) {
window.chatContext.userJjb = data.new_jjb;
window.chatContext.myGold = data.new_jjb;
// 派发更新全局 UI 的事件,类似钓鱼的更新
window.dispatchEvent(new CustomEvent('update-user-points', { detail: { points: data.new_jjb }}));
}
// 播放到账特定金币音效
if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') {
EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{});
}
if (data.level_up) {
window.chatToast.show({
title: '等级提升',
message: `恭喜!您的等级提升到了 ${data.new_level_name}`,
icon: '🌟',
color: '#8b5cf6',
duration: 5000
});
setTimeout(() => {
// 触发烟花庆贺
if(typeof EffectManager !== 'undefined') {
EffectManager.play('fireworks');
}
}, 500);
}
} else {
// 领奖失败,可能是超出次数或防刷限制
this.status = 'completed';
window.chatToast.show({ title: '出错了', message: data.message || '领奖失败,请稍后再试。', icon: '❌', color: '#ef4444', duration: 4000 });
}
} catch (error) {
this.status = 'completed';
window.chatToast.show({ title: '网络错误', message: '网络请求失败,请检查连接。', icon: '⚠️', color: '#ef4444' });
}
}
}));
});
</script>