From 8a690ac40d7cf34e71a1524ea0e5eadb0e733125 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 25 Apr 2026 14:25:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=B5=9A=E9=92=B1=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/chat-room.js | 7 + resources/js/chat-room/earn-panel.js | 399 ++++++++++++++++++ .../chat/partials/games/earn-panel.blade.php | 246 +---------- 3 files changed, 407 insertions(+), 245 deletions(-) create mode 100644 resources/js/chat-room/earn-panel.js diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 53c5c0a..76a5df4 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -8,6 +8,7 @@ * - chat-bot.js:处理 AI 小班长发送消息和清空上下文。 * - dialog.js:提供 window.chatDialog 全局弹窗。 * - daily-sign-in.js:处理每日签到弹窗与补签入口。 + * - earn-panel.js:提供看视频赚钱 earnPanelData Alpine 组件和播放器加载入口。 * - font-size.js:处理聊天输入/消息字号设置。 * - image-upload.js:处理聊天图片上传入口。 * - composer.js:处理聊天输入框、发送按钮和快捷操作。 @@ -50,6 +51,7 @@ export { bindChatBanner } from "./chat-room/banner.js"; export { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js"; export { bindGlobalDialogControls } from "./chat-room/dialog.js"; export { bindDailySignInControls } from "./chat-room/daily-sign-in.js"; +export { bindEarnPanelControls, createEarnPanelData } from "./chat-room/earn-panel.js"; export { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; export { bindChatImageUploadControl } from "./chat-room/image-upload.js"; export { bindChatComposerControls } from "./chat-room/composer.js"; @@ -143,6 +145,7 @@ import { bindChatBanner } from "./chat-room/banner.js"; import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js"; import { bindGlobalDialogControls } from "./chat-room/dialog.js"; import { bindDailySignInControls } from "./chat-room/daily-sign-in.js"; +import { bindEarnPanelControls, createEarnPanelData } from "./chat-room/earn-panel.js"; import { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; import { bindChatImageUploadControl } from "./chat-room/image-upload.js"; import { bindChatComposerControls } from "./chat-room/composer.js"; @@ -243,6 +246,8 @@ if (typeof window !== "undefined") { sendToChatBot, bindGlobalDialogControls, bindDailySignInControls, + bindEarnPanelControls, + createEarnPanelData, bindLotteryPanelControls, closeLotteryPanel, lotteryPanel, @@ -391,6 +396,7 @@ if (typeof window !== "undefined") { window.fetchBankRanking = fetchBankRanking; window.fortunePanel = fortunePanel; window.closeLotteryPanel = closeLotteryPanel; + window.createEarnPanelData = createEarnPanelData; window.deferChatGameBootstrap = deferChatGameBootstrap; window.lotteryPanel = lotteryPanel; window.openGameHall = openGameHall; @@ -407,6 +413,7 @@ if (typeof window !== "undefined") { bindAppointmentAnnouncementControls(); bindGlobalDialogControls(); bindDailySignInControls(); + bindEarnPanelControls(); bindLotteryPanelControls(); bindChatFontSizeControl(); bindChatImageUploadControl(); diff --git a/resources/js/chat-room/earn-panel.js b/resources/js/chat-room/earn-panel.js new file mode 100644 index 0000000..649f421 --- /dev/null +++ b/resources/js/chat-room/earn-panel.js @@ -0,0 +1,399 @@ +// 聊天室赚钱面板,注册 earnPanelData Alpine 组件并集中管理 FluidPlayer 加载。 + +const FLUID_PLAYER_SCRIPT_URL = "https://cdn.fluidplayer.com/v3/current/fluidplayer.min.js"; +const FALLBACK_VIDEO_SOURCE = "https://cdn.fluidplayer.com/videos/valerian-1080p.mkv"; +const VAST_TAG_URL = "https://s.magsrv.com/v1/vast.php?idzone=5889208"; + +let earnPanelControlsBound = false; +let earnPanelRegistered = false; +let fluidPlayerScriptPromise = null; + +/** + * 读取 CSRF Token。 + * + * @returns {string} + */ +function csrf() { + return document.querySelector('meta[name="csrf-token"]')?.content || ""; +} + +/** + * 显示统一 Toast,避免面板代码直接依赖未初始化对象。 + * + * @param {object} options + * @returns {void} + */ +function showToast(options) { + window.chatToast?.show?.(options); +} + +/** + * 按需加载 FluidPlayer CDN 脚本。 + * + * @returns {Promise} + */ +function loadFluidPlayerScript() { + if (typeof window.fluidPlayer === "function") { + return Promise.resolve(); + } + + if (fluidPlayerScriptPromise) { + return fluidPlayerScriptPromise; + } + + fluidPlayerScriptPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector(`script[src="${FLUID_PLAYER_SCRIPT_URL}"]`); + if (existingScript) { + existingScript.addEventListener("load", () => resolve(), { once: true }); + existingScript.addEventListener("error", () => reject(new Error("FluidPlayer 加载失败")), { once: true }); + return; + } + + const script = document.createElement("script"); + script.src = FLUID_PLAYER_SCRIPT_URL; + script.async = true; + script.addEventListener("load", () => resolve(), { once: true }); + script.addEventListener("error", () => reject(new Error("FluidPlayer 加载失败")), { once: true }); + document.head.appendChild(script); + }); + + return fluidPlayerScriptPromise; +} + +/** + * 销毁播放器实例并吞掉第三方库的重复销毁异常。 + * + * @param {object|null} player + * @returns {void} + */ +function destroyPlayer(player) { + if (!player) { + return; + } + + try { + player.destroy(); + } catch (error) { + // FluidPlayer 重复销毁时可能抛错,面板关闭流程应继续执行。 + } +} + +/** + * 重建原始 video DOM,避免 FluidPlayer 包裹后的节点影响下一次初始化。 + * + * @returns {void} + */ +function restoreVideoDOM() { + const wrapper = document.getElementById("video-wrapper"); + if (!wrapper) { + return; + } + + wrapper.innerHTML = ``; +} + +/** + * 创建赚钱面板 Alpine 组件。 + * + * @returns {object} + */ +export function createEarnPanelData() { + return { + isOpen: false, + status: "idle", + countdown: "获取中...", + timer: null, + player: null, + videoFinished: false, + + /** + * 打开面板并提前加载播放器脚本。 + * + * @returns {void} + */ + openPanel() { + this.isOpen = true; + void loadFluidPlayerScript().catch(() => { + showToast({ title: "播放器加载失败", message: "广告播放器暂时不可用,请稍后再试。", icon: "⚠️", color: "#ef4444" }); + }); + + if (this.status !== "claimed" && this.status !== "claiming") { + this.resetPanelState(); + } + }, + + /** + * 关闭面板并释放播放器资源。 + * + * @returns {void} + */ + closePanel() { + this.isOpen = false; + this.stopTimer(); + + if (this.status === "watching") { + showToast({ title: "取消提示", message: "已取消观看,没有扣除观影次数。", icon: "ℹ️", color: "#3b82f6" }); + } + + destroyPlayer(this.player); + this.player = null; + restoreVideoDOM(); + this.status = "idle"; + }, + + /** + * 重置面板到待观看状态。 + * + * @returns {void} + */ + resetPanelState() { + this.status = "idle"; + this.countdown = "获取中..."; + this.videoFinished = false; + this.stopTimer(); + destroyPlayer(this.player); + this.player = null; + restoreVideoDOM(); + }, + + /** + * 领奖后延迟重置,给用户保留到账反馈。 + * + * @returns {void} + */ + resetPanel() { + this.closePanel(); + window.setTimeout(() => { + this.resetPanelState(); + }, 300); + }, + + /** + * 标记观看完成并清理播放器。 + * + * @returns {void} + */ + completeWatch() { + this.videoFinished = true; + this.stopTimer(); + this.status = "completed"; + destroyPlayer(this.player); + this.player = null; + restoreVideoDOM(); + window.EffectSounds?.ding?.(); + }, + + /** + * 开始观看广告并启动进度轮询。 + * + * @returns {Promise} + */ + async startWatching() { + this.status = "watching"; + this.countdown = "请求广告..."; + this.videoFinished = false; + this.stopTimer(); + + try { + await loadFluidPlayerScript(); + } catch (error) { + this.status = "idle"; + showToast({ title: "播放器加载失败", message: "广告播放器暂时不可用,请稍后再试。", icon: "⚠️", color: "#ef4444" }); + return; + } + + this.initPlayer(); + let elapsedTime = 0; + + this.timer = window.setInterval(() => { + if (this.videoFinished) { + return; + } + + const video = document.getElementById("exoclick-video"); + elapsedTime += 0.5; + + if (!video) { + return; + } + + const duration = video.duration; + const currentTime = video.currentTime; + + if (!Number.isNaN(duration) && duration > 0) { + const left = Math.ceil(duration - currentTime); + this.countdown = `${left > 0 ? left : 0} 秒`; + + if (left <= 0 && currentTime > 0) { + this.completeWatch(); + } + return; + } + + // 无广告填充或缓冲过慢时,最多等待 20 秒后放行奖励。 + const fallbackLeft = Math.ceil(20 - elapsedTime); + if (fallbackLeft > 0) { + this.countdown = `缓冲/加载中... ${fallbackLeft}s`; + return; + } + + this.completeWatch(); + }, 500); + }, + + /** + * 初始化 FluidPlayer 实例。 + * + * @returns {void} + */ + initPlayer() { + const video = document.getElementById("exoclick-video"); + const doInit = () => { + try { + this.player = window.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: VAST_TAG_URL, + adText: "请观看广告获取金币", + }, + ], + }, + }); + } catch (error) { + // 第三方播放器初始化失败时保留面板,用户可关闭后重试。 + console.error("[EarnPanel] 播放器初始化未成功", error); + } + }; + + if (video && !video.paused) { + video.pause(); + video.addEventListener("pause", doInit, { once: true }); + return; + } + + doInit(); + }, + + /** + * 停止观看进度计时器。 + * + * @returns {void} + */ + stopTimer() { + if (!this.timer) { + return; + } + + window.clearInterval(this.timer); + this.timer = null; + }, + + /** + * 向后端领取观看奖励。 + * + * @returns {Promise} + */ + async claimReward() { + if (this.status !== "completed") { + return; + } + + this.status = "claiming"; + + try { + const response = await fetch(window.chatContext.earnRewardUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-TOKEN": csrf(), + "Accept": "application/json", + }, + body: JSON.stringify({ + room_id: window.chatContext?.roomId || 0, + }), + }); + const data = await response.json(); + + if (response.ok && data.success) { + this.status = "claimed"; + showToast({ title: "奖励到账", message: data.message || "领取成功!", icon: "🎉", color: "#10b981" }); + + if (data.new_jjb !== undefined) { + window.chatContext.userJjb = data.new_jjb; + window.chatContext.myGold = data.new_jjb; + window.dispatchEvent(new CustomEvent("update-user-points", { detail: { points: data.new_jjb } })); + } + + window.EffectSounds?.ding?.(); + + if (data.level_up) { + showToast({ + title: "等级提升", + message: `恭喜!您的等级提升到了 ${data.new_level_name}!`, + icon: "🌟", + color: "#8b5cf6", + duration: 5000, + }); + window.setTimeout(() => { + window.EffectManager?.play?.("fireworks"); + }, 500); + } + return; + } + + this.status = "completed"; + showToast({ title: "出错了", message: data.message || "领奖失败,请稍后再试。", icon: "❌", color: "#ef4444", duration: 4000 }); + } catch (error) { + this.status = "completed"; + showToast({ title: "网络错误", message: "网络请求失败,请检查连接。", icon: "⚠️", color: "#ef4444" }); + } + }, + }; +} + +/** + * 注册赚钱面板 Alpine 组件。 + * + * @returns {void} + */ +function registerEarnPanelData() { + if (earnPanelRegistered || !window.Alpine) { + return; + } + + earnPanelRegistered = true; + window.Alpine.data("earnPanelData", createEarnPanelData); +} + +/** + * 绑定赚钱面板入口和 Alpine 注册事件。 + * + * @returns {void} + */ +export function bindEarnPanelControls() { + if (typeof window === "undefined" || earnPanelControlsBound) { + return; + } + + earnPanelControlsBound = true; + window.createEarnPanelData = createEarnPanelData; + window.openEarnPanel = () => { + window.dispatchEvent(new CustomEvent("open-earn-panel")); + }; + + document.addEventListener("alpine:init", registerEarnPanelData, { once: true }); + registerEarnPanelData(); +} diff --git a/resources/views/chat/partials/games/earn-panel.blade.php b/resources/views/chat/partials/games/earn-panel.blade.php index e5e4590..5938d6e 100644 --- a/resources/views/chat/partials/games/earn-panel.blade.php +++ b/resources/views/chat/partials/games/earn-panel.blade.php @@ -95,248 +95,4 @@ - - - - - +{{-- 赚钱面板 Alpine 组件与 FluidPlayer 加载已迁移到 resources/js/chat-room/earn-panel.js --}}