diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js
index 541519e..58d4516 100644
--- a/resources/js/chat-room.js
+++ b/resources/js/chat-room.js
@@ -31,7 +31,7 @@
* - holiday-modal.js:处理节日福利弹窗和系统消息入口。
* - initial-state.js:恢复首屏历史消息、欢迎消息、入场特效和挂起婚姻事件。
* - bank-modal.js:处理银行弹窗、转账、排行和标签切换。
- * - fishing.js:处理钓鱼入口与自动钓鱼相关交互。
+ * - fishing.js:处理钓鱼抛竿、收竿、浮漂和自动钓鱼循环。
* - fortune-panel.js:提供神秘占卜 fortunePanel Alpine 组件。
* - profile-controls.js:处理用户资料和资料相关按钮。
* - shop-controls.js:处理商店弹窗的基础按钮事件。
@@ -106,7 +106,7 @@ export {
switchBankTab,
toggleBankRankSort,
} from "./chat-room/bank-modal.js";
-export { bindFishingControls } from "./chat-room/fishing.js";
+export { bindFishingControls, checkAndAutoStartFishing, createBobber, reelFish, removeBobber, resetFishingBtn, startFishing, stopAutoFishing } from "./chat-room/fishing.js";
export { bindFortunePanelControls, fortunePanel } from "./chat-room/fortune-panel.js";
export { bindProfileControls } from "./chat-room/profile-controls.js";
export { bindShopControls } from "./chat-room/shop-controls.js";
@@ -200,7 +200,7 @@ import {
switchBankTab,
toggleBankRankSort,
} from "./chat-room/bank-modal.js";
-import { bindFishingControls } from "./chat-room/fishing.js";
+import { bindFishingControls, checkAndAutoStartFishing, createBobber, reelFish, removeBobber, resetFishingBtn, startFishing, stopAutoFishing } from "./chat-room/fishing.js";
import { bindFortunePanelControls, fortunePanel } from "./chat-room/fortune-panel.js";
import { bindProfileControls } from "./chat-room/profile-controls.js";
import { bindShopControls } from "./chat-room/shop-controls.js";
@@ -314,6 +314,13 @@ if (typeof window !== "undefined") {
switchBankTab,
toggleBankRankSort,
bindFishingControls,
+ checkAndAutoStartFishing,
+ createBobber,
+ reelFish,
+ removeBobber,
+ resetFishingBtn,
+ startFishing,
+ stopAutoFishing,
bindFortunePanelControls,
fortunePanel,
bindMarriageStatusControls,
@@ -408,6 +415,13 @@ if (typeof window !== "undefined") {
window.openLotteryPanel = openLotteryPanel;
window.openBankModal = openBankModal;
window.showLotteryMsg = showLotteryMsg;
+ window.checkAndAutoStartFishing = checkAndAutoStartFishing;
+ window.createBobber = createBobber;
+ window.reelFish = reelFish;
+ window.removeBobber = removeBobber;
+ window.resetFishingBtn = resetFishingBtn;
+ window.startFishing = startFishing;
+ window.stopAutoFishing = stopAutoFishing;
window.buyVip = buyVip;
window.closeVipModal = closeVipModal;
window.openVipModal = openVipModal;
diff --git a/resources/js/chat-room/fishing.js b/resources/js/chat-room/fishing.js
index 1497ca4..174e97c 100644
--- a/resources/js/chat-room/fishing.js
+++ b/resources/js/chat-room/fishing.js
@@ -1,13 +1,605 @@
-// 聊天室钓鱼入口事件绑定,先兼容存量全局 startFishing 实现。
+// 聊天室钓鱼小游戏模块,管理抛竿、收竿、自动钓鱼循环和浮漂交互。
+
+import { escapeHtml } from "./html.js";
let fishingEventsBound = false;
+let fishingTimer = null;
+let fishingReelTimeout = null;
+let fishToken = null;
+let autoFishing = false;
+let autoFishCooldownTimer = null;
+let autoFishCooldownCountdown = null;
/**
- * 绑定钓鱼按钮点击事件。
+ * 读取 CSRF Token。
+ *
+ * @returns {string}
+ */
+function csrf() {
+ return document.querySelector('meta[name="csrf-token"]')?.content || "";
+}
+
+/**
+ * 获取私聊消息容器,钓鱼提示沿用旧版展示位置。
+ *
+ * @returns {HTMLElement|null}
+ */
+function messageContainer() {
+ return document.getElementById("chat-messages-container2");
+}
+
+/**
+ * 判断当前是否允许自动滚动。
+ *
+ * @returns {boolean}
+ */
+function shouldAutoScroll() {
+ return typeof window.isChatAutoScrollEnabled === "function" ? window.isChatAutoScrollEnabled() : true;
+}
+
+/**
+ * 追加一条钓鱼提示消息。
+ *
+ * @param {string} html
+ * @returns {void}
+ */
+function appendFishingMessage(html) {
+ const container = messageContainer();
+ if (!container) {
+ return;
+ }
+
+ const line = document.createElement("div");
+ line.className = "msg-line";
+ line.innerHTML = html;
+ container.appendChild(line);
+
+ if (shouldAutoScroll()) {
+ container.scrollTop = container.scrollHeight;
+ }
+}
+
+/**
+ * 当前本地时间文本。
+ *
+ * @returns {string}
+ */
+function timeText() {
+ return new Date().toLocaleTimeString("zh-CN", { hour12: false });
+}
+
+/**
+ * 注入浮漂和自动钓鱼按钮需要的动画样式。
+ *
+ * @returns {void}
+ */
+function ensureFishingStyles() {
+ if (!document.getElementById("bobber-style")) {
+ const style = document.createElement("style");
+ style.id = "bobber-style";
+ style.textContent = `
+ @keyframes bobberFloat {
+ 0%,100% { transform: translateY(0) rotate(-8deg); }
+ 50% { transform: translateY(-10px) rotate(8deg); }
+ }
+ @keyframes bobberSink {
+ 0% { transform: translateY(0) scale(1); opacity:1; }
+ 30% { transform: translateY(12px) scale(1.3); opacity:1; }
+ 100% { transform: translateY(40px) scale(0.5); opacity:0; }
+ }
+ #fishing-bobber.sinking {
+ animation: bobberSink 1.5s forwards !important;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ if (!document.getElementById("auto-fish-stop-style")) {
+ const style = document.createElement("style");
+ style.id = "auto-fish-stop-style";
+ style.textContent = `
+ @keyframes autoFishBtnPulse {
+ 0%,100% { box-shadow: 0 4px 12px rgba(220,38,38,0.4); }
+ 50% { box-shadow: 0 4px 20px rgba(220,38,38,0.7); }
+ }
+ #auto-fish-stop-btn {
+ position: fixed;
+ z-index: 10000;
+ background: linear-gradient(135deg, #dc2626, #b91c1c);
+ color: #fff;
+ border: none;
+ border-radius: 20px;
+ padding: 8px 18px;
+ font-size: 13px;
+ font-weight: bold;
+ cursor: grab;
+ user-select: none;
+ animation: autoFishBtnPulse 1.8s ease-in-out infinite;
+ touch-action: none;
+ }
+ #auto-fish-stop-btn:active { cursor: grabbing; }
+ #auto-fish-stop-btn .drag-hint {
+ display: block;
+ font-size: 9px;
+ font-weight: normal;
+ opacity: .65;
+ margin-top: 1px;
+ text-align: center;
+ letter-spacing: .5px;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+}
+
+/**
+ * 创建浮漂 DOM 元素。
+ *
+ * @param {number} x 水平百分比
+ * @param {number} y 垂直百分比
+ * @returns {HTMLElement}
+ */
+export function createBobber(x, y) {
+ ensureFishingStyles();
+
+ const bobber = document.createElement("div");
+ bobber.id = "fishing-bobber";
+ bobber.style.cssText = `
+ position: fixed;
+ left: ${Number(x) || 50}vw;
+ top: ${Number(y) || 50}vh;
+ font-size: 28px;
+ cursor: pointer;
+ z-index: 9999;
+ animation: bobberFloat 1.2s ease-in-out infinite;
+ filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4));
+ user-select: none;
+ transition: transform 0.3s;
+ `;
+ bobber.textContent = "🪝";
+ bobber.title = "鱼上钩了!快点击!";
+
+ return bobber;
+}
+
+/**
+ * 移除当前浮漂。
+ *
+ * @returns {void}
+ */
+export function removeBobber() {
+ document.getElementById("fishing-bobber")?.remove();
+}
+
+/**
+ * 获取钓鱼按钮。
+ *
+ * @returns {HTMLButtonElement|null}
+ */
+function fishingButton() {
+ const button = document.getElementById("fishing-btn");
+
+ return button instanceof HTMLButtonElement ? button : null;
+}
+
+/**
+ * 设置钓鱼按钮文案和禁用状态。
+ *
+ * @param {string} text
+ * @param {boolean} disabled
+ * @returns {void}
+ */
+function setFishingButton(text, disabled) {
+ const button = fishingButton();
+ if (!button) {
+ return;
+ }
+
+ button.textContent = text;
+ button.disabled = disabled;
+}
+
+/**
+ * 启动自动钓鱼冷却倒计时。
+ *
+ * @param {number} cooldown
+ * @returns {void}
+ */
+function startAutoFishingCooldown(cooldown) {
+ let remaining = cooldown;
+ setFishingButton(`⏳ 冷却 ${remaining}s`, true);
+ showAutoFishStopButton(cooldown);
+
+ autoFishCooldownCountdown = window.setInterval(() => {
+ remaining -= 1;
+ setFishingButton(`⏳ 冷却 ${remaining}s`, true);
+
+ if (remaining <= 0) {
+ window.clearInterval(autoFishCooldownCountdown);
+ autoFishCooldownCountdown = null;
+ }
+ }, 1000);
+
+ autoFishCooldownTimer = window.setTimeout(() => {
+ autoFishCooldownTimer = null;
+ hideAutoFishStopButton();
+
+ if (autoFishing) {
+ void startFishing();
+ }
+ }, cooldown * 1000);
+}
+
+/**
+ * 展示可拖动的停止自动钓鱼按钮。
+ *
+ * @param {number} cooldown
+ * @returns {void}
+ */
+function showAutoFishStopButton(cooldown) {
+ if (document.getElementById("auto-fish-stop-btn")) {
+ return;
+ }
+
+ ensureFishingStyles();
+
+ const button = document.createElement("button");
+ button.id = "auto-fish-stop-btn";
+ button.innerHTML = `🛑 停止自动钓鱼冷却 ${Number(cooldown) || 0}s · 可拖动`;
+
+ try {
+ const saved = JSON.parse(window.localStorage.getItem("autoFishBtnPos") || "null");
+ if (saved) {
+ button.style.left = `${Number(saved.left) || 0}px`;
+ button.style.top = `${Number(saved.top) || 0}px`;
+ } else {
+ button.style.bottom = "80px";
+ button.style.right = "20px";
+ }
+ } catch (error) {
+ button.style.bottom = "80px";
+ button.style.right = "20px";
+ }
+
+ bindAutoFishStopDrag(button);
+ document.body.appendChild(button);
+}
+
+/**
+ * 给停止自动钓鱼按钮绑定拖拽和点击停止事件。
+ *
+ * @param {HTMLButtonElement} button
+ * @returns {void}
+ */
+function bindAutoFishStopDrag(button) {
+ let isDragging = false;
+ let startX = 0;
+ let startY = 0;
+ let startLeft = 0;
+ let startTop = 0;
+
+ const dragStart = (event) => {
+ const rect = button.getBoundingClientRect();
+ button.style.left = `${rect.left}px`;
+ button.style.top = `${rect.top}px`;
+ button.style.right = "auto";
+ button.style.bottom = "auto";
+
+ isDragging = false;
+ const point = event.touches ? event.touches[0] : event;
+ startX = point.clientX;
+ startY = point.clientY;
+ startLeft = rect.left;
+ startTop = rect.top;
+
+ document.addEventListener("mousemove", dragMove, { passive: false });
+ document.addEventListener("mouseup", dragEnd);
+ document.addEventListener("touchmove", dragMove, { passive: false });
+ document.addEventListener("touchend", dragEnd);
+ };
+
+ const dragMove = (event) => {
+ event.preventDefault();
+ const point = event.touches ? event.touches[0] : event;
+ const dx = point.clientX - startX;
+ const dy = point.clientY - startY;
+
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
+ isDragging = true;
+ }
+
+ if (!isDragging) {
+ return;
+ }
+
+ const nextLeft = Math.max(0, Math.min(window.innerWidth - button.offsetWidth, startLeft + dx));
+ const nextTop = Math.max(0, Math.min(window.innerHeight - button.offsetHeight, startTop + dy));
+ button.style.left = `${nextLeft}px`;
+ button.style.top = `${nextTop}px`;
+ };
+
+ const dragEnd = () => {
+ document.removeEventListener("mousemove", dragMove);
+ document.removeEventListener("mouseup", dragEnd);
+ document.removeEventListener("touchmove", dragMove);
+ document.removeEventListener("touchend", dragEnd);
+
+ if (isDragging) {
+ window.localStorage.setItem("autoFishBtnPos", JSON.stringify({
+ left: Number.parseInt(button.style.left, 10),
+ top: Number.parseInt(button.style.top, 10),
+ }));
+ }
+ };
+
+ button.addEventListener("mousedown", dragStart);
+ button.addEventListener("touchstart", dragStart, { passive: true });
+ button.addEventListener("click", () => {
+ if (!isDragging) {
+ stopAutoFishing();
+ }
+ });
+}
+
+/**
+ * 隐藏停止自动钓鱼按钮。
+ *
+ * @returns {void}
+ */
+function hideAutoFishStopButton() {
+ document.getElementById("auto-fish-stop-btn")?.remove();
+}
+
+/**
+ * 开始钓鱼:调用抛竿接口并显示浮漂。
+ *
+ * @returns {Promise}
+ */
+export async function startFishing() {
+ setFishingButton("🎣 抛竿中...", true);
+
+ try {
+ const response = await fetch(window.chatContext.fishCastUrl, {
+ method: "POST",
+ headers: {
+ "X-CSRF-TOKEN": csrf(),
+ "Accept": "application/json",
+ },
+ });
+ const data = await response.json();
+
+ if (!response.ok || data.status !== "success") {
+ window.chatDialog?.alert?.(data.message || "钓鱼失败", "操作失败", "#cc4444");
+ setFishingButton("🎣 钓鱼", false);
+ return;
+ }
+
+ fishToken = data.token;
+ autoFishing = Boolean(data.auto_fishing);
+ appendFishingMessage(`🎣【钓鱼】${escapeHtml(data.message)}(${timeText()})`);
+ setFishingButton("🎣 等待中...", true);
+
+ const bobber = createBobber(data.bobber_x, data.bobber_y);
+ document.body.appendChild(bobber);
+
+ fishingTimer = window.setTimeout(() => {
+ bobber.classList.add("sinking");
+ bobber.textContent = "🐟";
+
+ if (data.auto_fishing) {
+ appendFishingMessage(`🎣 自动钓鱼卡生效!自动收竿中... (剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)`);
+ fishingReelTimeout = window.setTimeout(() => {
+ removeBobber();
+ void reelFish();
+ }, 1800);
+ return;
+ }
+
+ appendFishingMessage('🐟 鱼上钩了!快点击屏幕上的浮漂!');
+ setFishingButton("🎣 点击浮漂!", true);
+ bobber.addEventListener("click", () => {
+ removeBobber();
+
+ if (fishingReelTimeout) {
+ window.clearTimeout(fishingReelTimeout);
+ fishingReelTimeout = null;
+ }
+
+ void reelFish();
+ }, { once: true });
+
+ fishingReelTimeout = window.setTimeout(() => {
+ removeBobber();
+ fishToken = null;
+ appendFishingMessage('💨 你反应太慢了,鱼跑掉了...');
+ resetFishingBtn();
+ }, 8000);
+ }, Number(data.wait_time || 0) * 1000);
+ } catch (error) {
+ window.chatDialog?.alert?.(`网络错误:${error.message}`, "网络异常", "#cc4444");
+ removeBobber();
+ setFishingButton("🎣 钓鱼", false);
+ }
+}
+
+/**
+ * 收竿并提交本次钓鱼 token。
+ *
+ * @returns {Promise}
+ */
+export async function reelFish() {
+ setFishingButton("🎣 拉竿中...", true);
+
+ if (fishingReelTimeout) {
+ window.clearTimeout(fishingReelTimeout);
+ fishingReelTimeout = null;
+ }
+
+ const token = fishToken;
+ fishToken = null;
+
+ try {
+ const response = await fetch(window.chatContext.fishReelUrl, {
+ method: "POST",
+ headers: {
+ "X-CSRF-TOKEN": csrf(),
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ token }),
+ });
+ const data = await response.json();
+
+ if (response.ok && data.status === "success") {
+ const result = data.result || {};
+ const color = Number(result.exp || 0) >= 0 ? "#16a34a" : "#dc2626";
+ appendFishingMessage(
+ `${escapeHtml(result.emoji || "🎣")}【钓鱼结果】${escapeHtml(result.message || "")}` +
+ ` (经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0})` +
+ `(${timeText()})`,
+ );
+
+ if (autoFishing) {
+ startAutoFishingCooldown(Number(data.cooldown_seconds) || 300);
+ return;
+ }
+ } else {
+ appendFishingMessage(`【钓鱼】${escapeHtml(data.message || "操作失败")}(${timeText()})`);
+
+ if (autoFishing) {
+ retryAutoFishing();
+ return;
+ }
+
+ autoFishing = false;
+ }
+ } catch (error) {
+ if (autoFishing) {
+ appendFishingMessage('⚠️ 网络异常,5秒后自动重试钓鱼...');
+ retryAutoFishing();
+ return;
+ }
+
+ window.chatDialog?.alert?.(`网络错误:${error.message}`, "网络异常", "#cc4444");
+ autoFishing = false;
+ }
+
+ resetFishingBtn();
+}
+
+/**
+ * 自动钓鱼异常时短暂等待后重试。
+ *
+ * @returns {void}
+ */
+function retryAutoFishing() {
+ setFishingButton("⏳ 重试中...", true);
+ autoFishCooldownTimer = window.setTimeout(() => {
+ autoFishCooldownTimer = null;
+
+ if (autoFishing) {
+ void startFishing();
+ }
+ }, 5000);
+}
+
+/**
+ * 手动停止自动钓鱼循环。
+ *
+ * @returns {void}
+ */
+export function stopAutoFishing() {
+ autoFishing = false;
+ clearAutoFishingTimers();
+ hideAutoFishStopButton();
+ appendFishingMessage('🛑 已停止自动钓鱼。');
+ resetFishingBtn();
+}
+
+/**
+ * 清理自动钓鱼冷却计时器。
+ *
+ * @returns {void}
+ */
+function clearAutoFishingTimers() {
+ if (autoFishCooldownTimer) {
+ window.clearTimeout(autoFishCooldownTimer);
+ autoFishCooldownTimer = null;
+ }
+
+ if (autoFishCooldownCountdown) {
+ window.clearInterval(autoFishCooldownCountdown);
+ autoFishCooldownCountdown = null;
+ }
+}
+
+/**
+ * 重置钓鱼按钮和临时状态。
+ *
+ * @returns {void}
+ */
+export function resetFishingBtn() {
+ autoFishing = false;
+ clearAutoFishingTimers();
+ hideAutoFishStopButton();
+
+ if (fishingTimer) {
+ window.clearTimeout(fishingTimer);
+ fishingTimer = null;
+ }
+
+ if (fishingReelTimeout) {
+ window.clearTimeout(fishingReelTimeout);
+ fishingReelTimeout = null;
+ }
+
+ setFishingButton("🎣 钓鱼", false);
+ removeBobber();
+}
+
+/**
+ * 检查自动钓鱼卡状态并恢复自动循环。
+ *
+ * @returns {void}
+ */
+export function checkAndAutoStartFishing() {
+ const minutesLeft = Number(window.chatContext?.autoFishingMinutesLeft || 0);
+ const initialCooldown = Number(window.chatContext?.fishingCooldownSeconds || 0);
+
+ if (minutesLeft <= 0 || autoFishing) {
+ return;
+ }
+
+ autoFishing = true;
+
+ if (initialCooldown > 0) {
+ console.log(`检测到自动钓鱼卡有效,恢复钓鱼状态,剩余冷却 ${initialCooldown}s`);
+ startAutoFishingCooldown(initialCooldown);
+ return;
+ }
+
+ console.log("检测到自动钓鱼卡有效,自动抛竿");
+ void startFishing();
+}
+
+/**
+ * 绑定钓鱼按钮、全局兼容入口和清屏恢复事件。
*
* @returns {void}
*/
export function bindFishingControls() {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ window.createBobber = createBobber;
+ window.removeBobber = removeBobber;
+ window.startFishing = startFishing;
+ window.reelFish = reelFish;
+ window.stopAutoFishing = stopAutoFishing;
+ window.resetFishingBtn = resetFishingBtn;
+ window.checkAndAutoStartFishing = checkAndAutoStartFishing;
+
if (fishingEventsBound || typeof document === "undefined") {
return;
}
@@ -19,10 +611,15 @@ export function bindFishingControls() {
}
event.preventDefault();
+ void startFishing();
+ });
- // 钓鱼完整流程仍在 fishing-panel.blade.php,当前模块只统一按钮事件入口。
- if (typeof window.startFishing === "function") {
- window.startFishing();
- }
+ document.addEventListener("DOMContentLoaded", () => {
+ checkAndAutoStartFishing();
+ window.addEventListener("chat:screen-cleared", () => {
+ if (!autoFishing) {
+ checkAndAutoStartFishing();
+ }
+ });
});
}
diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php
index 21f0e0f..56c24e2 100644
--- a/resources/views/chat/frame.blade.php
+++ b/resources/views/chat/frame.blade.php
@@ -30,6 +30,11 @@
$operatorActivePosition = Auth::user()->activePosition?->load('position.department')->position;
$operatorDepartmentRank = (int) ($operatorActivePosition?->department?->rank ?? 0);
$operatorPositionRank = (int) ($operatorActivePosition?->rank ?? 0);
+ // 自动钓鱼状态下发给 Vite 模块,避免钓鱼面板继续在 Blade 内写业务脚本。
+ $autoFishingMinutesLeft = app(\App\Services\ShopService::class)->getActiveAutoFishingMinutesLeft(Auth::user());
+ $fishingCooldownKey = 'fishing:cd:'.Auth::id();
+ $fishingCooldownSeconds = \Illuminate\Support\Facades\Redis::ttl($fishingCooldownKey);
+ $fishingCooldownSeconds = $fishingCooldownSeconds > 0 ? $fishingCooldownSeconds : 0;
@endphp
+{{--
+ 钓鱼小游戏业务脚本已迁移到 resources/js/chat-room/fishing.js。
+ 自动钓鱼初始状态由 resources/views/chat/frame.blade.php 下发到 window.chatContext。
+--}}