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 代码
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $room->name ?? '聊天室' }} - 飘落流星</title>
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta http-equiv="Delegate-CH" content="Sec-CH-UA https://s.magsrv.com; Sec-CH-UA-Mobile https://s.magsrv.com; Sec-CH-UA-Arch https://s.magsrv.com; Sec-CH-UA-Model https://s.magsrv.com; Sec-CH-UA-Platform https://s.magsrv.com; Sec-CH-UA-Platform-Version https://s.magsrv.com; Sec-CH-UA-Bitness https://s.magsrv.com; Sec-CH-UA-Full-Version-List https://s.magsrv.com; Sec-CH-UA-Full-Version https://s.magsrv.com;">
|
||||
@php
|
||||
// 从 sysparam 读取权限等级配置
|
||||
$levelWarn = (int) \App\Models\Sysparam::getValue('level_warn', '5');
|
||||
@@ -112,7 +113,8 @@
|
||||
weddingSetupUrl: (id) => `/wedding/${id}/setup`,
|
||||
claimEnvelopeUrl: (id, ceremonyId) => `/wedding/${id}/claim`,
|
||||
envelopeStatusUrl: (id) => `/wedding/${id}/envelope-status`,
|
||||
}
|
||||
},
|
||||
earnRewardUrl: "{{ route('earn.video_reward') }}"
|
||||
};
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
@@ -194,6 +196,7 @@
|
||||
@include('chat.partials.games.fishing-panel')
|
||||
@include('chat.partials.games.game-hall')
|
||||
@include('chat.partials.games.gomoku-panel')
|
||||
@include('chat.partials.games.earn-panel')
|
||||
|
||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||
<script src="/js/effects/effect-sounds.js"></script>
|
||||
|
||||
342
resources/views/chat/partials/games/earn-panel.blade.php
Normal file
342
resources/views/chat/partials/games/earn-panel.blade.php
Normal file
@@ -0,0 +1,342 @@
|
||||
{{--
|
||||
文件功能:观看广告赚钱面板
|
||||
包含 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">×</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 元素常驻 DOM,preload=none 防止空闲时预加载。
|
||||
必须保留 fallback source:VAST 失败时 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>
|
||||
@@ -1,422 +1 @@
|
||||
{{--
|
||||
文件功能:神秘箱子游戏前台UI组件
|
||||
|
||||
功能描述:
|
||||
- 右下角悬浮提示标(检测到可领取箱子时显示,支持拖动移位)
|
||||
- 监听聊天消息事件,识别公屏暗号提示后自动出现
|
||||
- 用户在聊天框输入暗号(由前端拦截 /mystery-box/claim 接口)
|
||||
- 或点击悬浮图标展开快速输入界面
|
||||
- 开箱结果展示 toast 通知
|
||||
--}}
|
||||
|
||||
{{-- ─── 神秘箱子悬浮提示(可拖动) ─── --}}
|
||||
<div id="mystery-box-fab" x-data="mysteryBoxFab()" x-show="visible" x-cloak
|
||||
:style="'position:fixed; left:' + posX + 'px; top:' + posY + 'px; z-index:9880; touch-action:none; user-select:none;'"
|
||||
@pointerdown.prevent="startDrag($event)" @pointermove.window="onDrag($event)" @pointerup.window="endDrag($event)"
|
||||
@pointercancel.window="endDrag($event)">
|
||||
|
||||
{{-- 悬浮圆形按钮 --}}
|
||||
<button
|
||||
style="width:56px; height:56px; border-radius:50%; border:none; position:relative;
|
||||
font-size:24px; display:flex; align-items:center; justify-content:center;
|
||||
animation:mb-pulse 1.8s ease-in-out infinite; user-select:none;"
|
||||
:style="[
|
||||
dragging ? 'cursor:grabbing;' : 'cursor:grab;',
|
||||
boxType === 'rare' ?
|
||||
'background:linear-gradient(135deg,#7c3aed,#a78bfa); box-shadow:0 4px 24px rgba(124,58,237,.6);' :
|
||||
boxType === 'trap' ?
|
||||
'background:linear-gradient(135deg,#7f1d1d,#dc2626); box-shadow:0 4px 24px rgba(220,38,38,.6);' :
|
||||
'background:linear-gradient(135deg,#065f46,#10b981); box-shadow:0 4px 24px rgba(16,185,129,.6);'
|
||||
]"
|
||||
title="神秘箱子开箱中!(可拖动)">
|
||||
<span x-text="boxEmoji">📦</span>
|
||||
{{-- 倒计时badge --}}
|
||||
<span x-show="secondsLeft !== null && secondsLeft <= 30"
|
||||
style="position:absolute; top:-6px; right:-6px; background:#ef4444; color:#fff;
|
||||
font-size:10px; font-weight:bold; border-radius:10px; padding:1px 5px; min-width:18px; text-align:center;"
|
||||
x-text="secondsLeft">
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{{-- 悬浮提示标签 --}}
|
||||
<div style="position:absolute; left:62px; top:50%; transform:translateY(-50%);
|
||||
background:rgba(0,0,0,.85); color:#fff; border-radius:8px; padding:4px 10px;
|
||||
font-size:12px; white-space:nowrap; pointer-events:none;"
|
||||
:style="boxType === 'rare' ? 'border:1px solid rgba(167,139,250,.4);' :
|
||||
boxType === 'trap' ? 'border:1px solid rgba(239,68,68,.3);' :
|
||||
'border:1px solid rgba(16,185,129,.3);'">
|
||||
<span x-text="boxTypeName">神秘箱</span>
|
||||
<span x-show="secondsLeft !== null" style="margin-left:4px; opacity:.7;" x-text="'⏰ ' + secondsLeft + 's'">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 神秘箱子快捷输入面板 ─── --}}
|
||||
<div id="mystery-box-panel" x-data="mysteryBoxPanel()" x-show="show" x-cloak>
|
||||
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
style="position:fixed; inset:0; background:rgba(0,0,0,.75); z-index:9920;
|
||||
display:flex; align-items:center; justify-content:center;">
|
||||
|
||||
<div style="width:380px; max-width:94vw; border-radius:24px; overflow:hidden;
|
||||
font-family:system-ui,sans-serif; position:relative;"
|
||||
:style="boxType === 'rare' ?
|
||||
'box-shadow:0 24px 80px rgba(124,58,237,.5); background:linear-gradient(180deg,#1e1b4b,#2e1065);' :
|
||||
boxType === 'trap' ?
|
||||
'box-shadow:0 24px 80px rgba(220,38,38,.4); background:linear-gradient(180deg,#1c0606,#2d0909);' :
|
||||
'box-shadow:0 24px 80px rgba(16,185,129,.4); background:linear-gradient(180deg,#022c22,#064e3b);'">
|
||||
|
||||
{{-- ─── 顶部 ─── --}}
|
||||
<div style="padding:20px 22px 16px;"
|
||||
:style="boxType === 'rare' ?
|
||||
'background:linear-gradient(135deg,#4c1d95,#6d28d9,#7c3aed);' :
|
||||
boxType === 'trap' ?
|
||||
'background:linear-gradient(135deg,#7f1d1d,#b91c1c,#dc2626);' :
|
||||
'background:linear-gradient(135deg,#064e3b,#059669,#10b981);'">
|
||||
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<div style="font-size:36px;" x-text="boxEmoji">📦</div>
|
||||
<div>
|
||||
<div style="color:#fff; font-weight:900; font-size:17px;" x-text="boxTypeName + ' 开箱!'"></div>
|
||||
<div style="color:rgba(255,255,255,.6); font-size:12px; margin-top:2px;">
|
||||
输入暗号即可开箱,限时
|
||||
<span x-text="secondsLeft !== null ? secondsLeft + ' 秒' : ''"
|
||||
style="color:#fbbf24; font-weight:bold;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 倒计时进度条 --}}
|
||||
<div x-show="secondsLeft !== null && totalSeconds > 0"
|
||||
style="margin-top:10px; height:3px; background:rgba(255,255,255,.15); border-radius:2px; overflow:hidden;">
|
||||
<div style="height:100%; border-radius:2px; transition:width 1s linear;"
|
||||
:style="'width:' + Math.max(0, secondsLeft / totalSeconds * 100) + '%; background:' +
|
||||
(boxType === 'rare' ? '#c4b5fd' : boxType === 'trap' ? '#fca5a5' : '#6ee7b7')">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 输入区 ─── --}}
|
||||
<div style="padding:20px;">
|
||||
{{-- 奖励提示 --}}
|
||||
<div style="text-align:center; margin-bottom:14px; padding:10px; border-radius:12px;"
|
||||
:style="boxType === 'rare' ?
|
||||
'background:rgba(124,58,237,.15); border:1px solid rgba(167,139,250,.25);' :
|
||||
boxType === 'trap' ?
|
||||
'background:rgba(220,38,38,.1); border:1px solid rgba(248,113,113,.2);' :
|
||||
'background:rgba(16,185,129,.1); border:1px solid rgba(52,211,153,.2);'">
|
||||
<div style="font-size:13px; opacity:.8;"
|
||||
x-text="
|
||||
boxType === 'rare' ? '✨ 稀有箱 · 奖励丰厚,手速要快!' :
|
||||
boxType === 'trap' ? '⚠️ 黑化箱 · 开了可能倒扣金币!谨慎开启!' :
|
||||
'🎁 发送正确暗号即可开箱领奖'
|
||||
"
|
||||
style="color:rgba(255,255,255,.75);"></div>
|
||||
</div>
|
||||
|
||||
{{-- 暗号输入框 --}}
|
||||
<div style="margin-bottom:12px;">
|
||||
<input type="text" x-model="inputCode" placeholder="请输入暗号(区分大小写)"
|
||||
@keydown.enter="doClaimFromPanel()" @input="inputCode = $event.target.value.toUpperCase()"
|
||||
style="width:100%; border-radius:10px; padding:12px 14px; font-size:15px;
|
||||
font-weight:bold; text-align:center; letter-spacing:4px; text-transform:uppercase;
|
||||
background:rgba(255,255,255,.1); border:2px solid rgba(255,255,255,.2);
|
||||
color:#fff; box-sizing:border-box; outline:none; transition:border-color .15s;"
|
||||
:style="boxType === 'rare' ? '&:focus { border-color:#a78bfa; }' : ''">
|
||||
</div>
|
||||
|
||||
{{-- 提交按钮 --}}
|
||||
<button @click="doClaimFromPanel()" :disabled="!inputCode.trim() || claiming"
|
||||
style="width:100%; border:none; border-radius:12px; padding:13px; font-size:15px;
|
||||
font-weight:900; cursor:pointer; transition:all .2s; letter-spacing:2px;"
|
||||
:style="(!inputCode.trim() || claiming) ? {
|
||||
background: 'rgba(255,255,255,.08)',
|
||||
color: 'rgba(255,255,255,.3)',
|
||||
cursor: 'not-allowed'
|
||||
} : boxType === 'rare' ? {
|
||||
background: 'linear-gradient(135deg,#7c3aed,#8b5cf6,#a78bfa)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 20px rgba(124,58,237,.5)'
|
||||
} : boxType === 'trap' ? {
|
||||
background: 'linear-gradient(135deg,#b91c1c,#dc2626,#ef4444)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 20px rgba(220,38,38,.5)'
|
||||
} : {
|
||||
background: 'linear-gradient(135deg,#059669,#10b981,#34d399)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 20px rgba(16,185,129,.5)'
|
||||
}">
|
||||
<span x-text="claiming ? '🎁 开箱中…' : '🎁 开箱!'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- ─── 底部关闭 ─── --}}
|
||||
<div style="padding:8px 20px 12px; display:flex; justify-content:center;">
|
||||
<button @click="show = false"
|
||||
style="padding:6px 24px; background:rgba(255,255,255,.06); border:none; border-radius:20px;
|
||||
font-size:12px; color:rgba(255,255,255,.4); cursor:pointer; transition:all .15s;"
|
||||
onmouseover="this.style.background='rgba(255,255,255,.12)'"
|
||||
onmouseout="this.style.background='rgba(255,255,255,.06)'">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes mb-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mb-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 神秘箱子悬浮按钮 Alpine 组件
|
||||
* 支持拖动 + 位置持久化 + 倒计时显示
|
||||
*/
|
||||
function mysteryBoxFab() {
|
||||
const STORAGE_KEY = 'mystery_box_fab_pos';
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
|
||||
return {
|
||||
visible: false,
|
||||
posX: saved?.x ?? 18,
|
||||
posY: saved?.y ?? 240,
|
||||
dragging: false,
|
||||
_startX: 0,
|
||||
_startY: 0,
|
||||
_origX: 0,
|
||||
_origY: 0,
|
||||
_moved: false,
|
||||
|
||||
boxType: 'normal',
|
||||
boxTypeName: '普通神秘箱',
|
||||
boxEmoji: '📦',
|
||||
secondsLeft: null,
|
||||
totalSeconds: null,
|
||||
_timer: null,
|
||||
|
||||
/**
|
||||
* 初始化:轮询接口检测箱子
|
||||
*/
|
||||
init() {
|
||||
this.checkStatus();
|
||||
// 每5秒轮询一次
|
||||
setInterval(() => this.checkStatus(), 5000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查当前是否有可领取的箱子
|
||||
*/
|
||||
async checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/mystery-box/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.active) {
|
||||
this.visible = true;
|
||||
this.boxType = data.box_type;
|
||||
this.boxTypeName = data.type_name;
|
||||
this.boxEmoji = data.type_emoji;
|
||||
this.secondsLeft = data.seconds_left;
|
||||
if (this.totalSeconds === null || data.seconds_left > this.totalSeconds) {
|
||||
this.totalSeconds = data.seconds_left;
|
||||
}
|
||||
this.startCountdown();
|
||||
|
||||
// ── 全局标志:供聊天框暗号拦截使用 ──
|
||||
window._mysteryBoxActive = true;
|
||||
window._mysteryBoxPasscode = data.passcode ?? null;
|
||||
|
||||
// 同步面板数据
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) {
|
||||
const pd = Alpine.$data(panel);
|
||||
pd.boxType = data.box_type;
|
||||
pd.boxTypeName = data.type_name;
|
||||
pd.boxEmoji = data.type_emoji;
|
||||
pd.secondsLeft = data.seconds_left;
|
||||
pd.totalSeconds = this.totalSeconds;
|
||||
}
|
||||
} else {
|
||||
this.visible = false;
|
||||
clearInterval(this._timer);
|
||||
|
||||
// ── 清除全局标志 ──
|
||||
window._mysteryBoxActive = false;
|
||||
window._mysteryBoxPasscode = null;
|
||||
|
||||
// 关闭面板
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) Alpine.$data(panel).show = false;
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动倒计时(本地)
|
||||
*/
|
||||
startCountdown() {
|
||||
clearInterval(this._timer);
|
||||
this._timer = setInterval(() => {
|
||||
if (this.secondsLeft !== null && this.secondsLeft > 0) {
|
||||
this.secondsLeft--;
|
||||
// 同步面板
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) Alpine.$data(panel).secondsLeft = this.secondsLeft;
|
||||
} else {
|
||||
clearInterval(this._timer);
|
||||
this.visible = false;
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 拖动开始
|
||||
*/
|
||||
startDrag(e) {
|
||||
this.dragging = true;
|
||||
this._moved = false;
|
||||
this._startX = e.clientX;
|
||||
this._startY = e.clientY;
|
||||
this._origX = this.posX;
|
||||
this._origY = this.posY;
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 拖动移动
|
||||
*/
|
||||
onDrag(e) {
|
||||
if (!this.dragging) return;
|
||||
const dx = e.clientX - this._startX;
|
||||
const dy = e.clientY - this._startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
|
||||
this.posX = Math.max(4, Math.min(window.innerWidth - 80, this._origX + dx));
|
||||
this.posY = Math.max(4, Math.min(window.innerHeight - 70, this._origY + dy));
|
||||
},
|
||||
|
||||
/**
|
||||
* 拖动结束,非拖动时打开面板
|
||||
*/
|
||||
endDrag(e) {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
x: this.posX,
|
||||
y: this.posY
|
||||
}));
|
||||
if (!this._moved) {
|
||||
// 打开快捷输入面板
|
||||
const panel = document.getElementById('mystery-box-panel');
|
||||
if (panel) Alpine.$data(panel).show = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 神秘箱子快捷输入面板 Alpine 组件
|
||||
*/
|
||||
function mysteryBoxPanel() {
|
||||
return {
|
||||
show: false,
|
||||
boxType: 'normal',
|
||||
boxTypeName: '神秘箱',
|
||||
boxEmoji: '📦',
|
||||
secondsLeft: null,
|
||||
totalSeconds: null,
|
||||
inputCode: '',
|
||||
claiming: false,
|
||||
|
||||
/**
|
||||
* 从面板提交开箱暗号
|
||||
*/
|
||||
async doClaimFromPanel() {
|
||||
if (!this.inputCode.trim() || this.claiming) return;
|
||||
this.claiming = true;
|
||||
|
||||
const result = await window.mysteryBoxClaim(this.inputCode.trim());
|
||||
|
||||
this.claiming = false;
|
||||
|
||||
if (result.ok) {
|
||||
this.show = false;
|
||||
this.inputCode = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局开箱函数(可被聊天框暗号处理逻辑调用)
|
||||
*
|
||||
* @param {string} passcode 用户输入的暗号
|
||||
* @returns {Promise<{ok: boolean, message?: string, reward?: number}>}
|
||||
*/
|
||||
window.mysteryBoxClaim = async function(passcode) {
|
||||
try {
|
||||
const res = await fetch('/mystery-box/claim', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
passcode
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
// 成功动画提示
|
||||
const isPositive = data.reward > 0;
|
||||
window.chatDialog?.alert(
|
||||
data.message || '开箱成功!',
|
||||
isPositive ? '🎉 恭喜!' : '☠️ 中了陷阱!',
|
||||
isPositive ? '#10b981' : '#ef4444',
|
||||
);
|
||||
// 更新全局金币余额
|
||||
if (window.__chatUser) window.__chatUser.jjb = data.balance;
|
||||
} else {
|
||||
window.chatDialog?.alert(data.message || '开箱失败', '提示', '#f59e0b');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
|
||||
return {
|
||||
ok: false
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{{-- 神秘箱子的前台 UI 弹窗和悬浮提醒已按照用户要求彻底取消 --}}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<div class="tool-btn" onclick="openShopModal()" title="购买道具">商店</div>
|
||||
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
|
||||
<div class="tool-btn" onclick="openGameHall()" title="娱乐游戏大厅">娱乐</div>
|
||||
<div class="tool-btn" onclick="window.dispatchEvent(new CustomEvent('open-earn-panel'))" title="看视频赚金币">赚钱</div>
|
||||
<div class="tool-btn" onclick="openBankModal()" title="银行存取金币">银行</div>
|
||||
<div class="tool-btn" onclick="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
|
||||
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
|
||||
|
||||
Reference in New Issue
Block a user