diff --git a/app/Http/Controllers/DailySignInController.php b/app/Http/Controllers/DailySignInController.php index 655d53b..b37a76f 100644 --- a/app/Http/Controllers/DailySignInController.php +++ b/app/Http/Controllers/DailySignInController.php @@ -185,9 +185,10 @@ class DailySignInController extends Controller .',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。'; } + // 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。 $quickButton = ''; return '【'.e($user->username).'】完成今日签到,连续签到 ' diff --git a/app/Http/Controllers/EarnController.php b/app/Http/Controllers/EarnController.php index e571f56..3692237 100644 --- a/app/Http/Controllers/EarnController.php +++ b/app/Http/Controllers/EarnController.php @@ -104,9 +104,10 @@ class EarnController extends Controller // 6. 广播全服系统消息 if ($roomId > 0) { + // 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。 $promoTag = ' 💰 看视频赚金币'; $sysMsg = [ diff --git a/app/Http/Controllers/FishingController.php b/app/Http/Controllers/FishingController.php index db8b94f..4ceb096 100644 --- a/app/Http/Controllers/FishingController.php +++ b/app/Http/Controllers/FishingController.php @@ -19,6 +19,7 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; use App\Models\GameConfig; use App\Models\Sysparam; +use App\Models\User; use App\Services\ChatStateService; use App\Services\FishingService; use App\Services\GameRoomScopeService; @@ -36,6 +37,9 @@ use Illuminate\Support\Str; */ class FishingController extends Controller { + /** + * 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。 + */ public function __construct( private readonly ChatStateService $chatState, private readonly VipService $vipService, @@ -84,6 +88,14 @@ class FishingController extends Controller ], 429); } + $tokenKey = "fishing:token:{$user->id}"; + if (Redis::exists($tokenKey)) { + $activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey); + if ($activeSessionResponse) { + return $activeSessionResponse; + } + } + // 2. 检查金币是否足够 $cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5')); if (($user->jjb ?? 0) < $cost) { @@ -93,34 +105,54 @@ class FishingController extends Controller ], 422); } - // 3. 扣除金币 - $this->currencyService->change( - $user, 'gold', -$cost, - CurrencySource::FISHING_COST, - "钓鱼抛竿消耗 {$cost} 金币", - $id, - ); - $user->refresh(); - - // 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲) + // 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲) $waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); $waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15')); $waitTime = rand($waitMin, $waitMax); $token = Str::random(32); - $tokenKey = "fishing:token:{$user->id}"; - // token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲 - // 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验 - Redis::setex($tokenKey, $waitTime + 13, json_encode([ + $tokenTtl = $waitTime + 13; + $tokenPayload = json_encode([ 'token' => $token, 'cast_at' => time(), 'wait_time' => $waitTime, - ])); + ]); - // 5. 生成随机浮漂坐标(百分比,避开边缘) + // 原子占用本次抛竿 token,避免多标签页自动钓鱼互相覆盖令牌。 + $reserved = Redis::command('set', [$tokenKey, $tokenPayload, 'EX', $tokenTtl, 'NX']); + if (! $reserved) { + $activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey); + if ($activeSessionResponse) { + return $activeSessionResponse; + } + + return response()->json([ + 'status' => 'error', + 'message' => '钓鱼状态同步中,请稍后重试。', + 'retry_after' => 3, + ], 409); + } + + try { + // token 占用成功后才扣金币,确保重复抛竿不会多扣费用。 + $this->currencyService->change( + $user, 'gold', -$cost, + CurrencySource::FISHING_COST, + "钓鱼抛竿消耗 {$cost} 金币", + $id, + ); + $user->refresh(); + } catch (\Throwable $exception) { + // 金币扣除失败时释放 token,避免用户被短时间卡在未收竿状态。 + Redis::del($tokenKey); + + throw $exception; + } + + // 4. 生成随机浮漂坐标(百分比,避开边缘) $bobberX = rand(15, 85); // 左右 15%~85% $bobberY = rand(20, 65); // 上下 20%~65% - // 6. 检查是否持有有效自动钓鱼卡 + // 5. 检查是否持有有效自动钓鱼卡 $autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user); return response()->json([ @@ -137,6 +169,37 @@ class FishingController extends Controller ]); } + /** + * 恢复已有钓鱼会话,避免刷新页面后丢失前端内存里的收竿令牌。 + */ + private function restoreActiveFishingSessionResponse(User $user, string $tokenKey): ?JsonResponse + { + $stored = json_decode((string) Redis::get($tokenKey), true); + if (! is_array($stored) || empty($stored['token'])) { + Redis::del($tokenKey); + + return null; + } + + $elapsed = time() - (int) ($stored['cast_at'] ?? 0); + $waitTime = max(0, (int) ($stored['wait_time'] ?? 0) - $elapsed); + $autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user); + + return response()->json([ + 'status' => 'success', + 'message' => '已恢复正在进行的钓鱼,请等待本次收竿。', + 'wait_time' => $waitTime, + 'bobber_x' => rand(15, 85), + 'bobber_y' => rand(20, 65), + 'token' => (string) $stored['token'], + 'auto_fishing' => $autoFishingMinutes > 0, + 'auto_fishing_minutes_left' => $autoFishingMinutes, + 'cost' => 0, + 'jjb' => $user->jjb, + 'restored' => true, + ]); + } + /** * 收竿 — 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。 * diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index ab4a6f3..df2e404 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -303,12 +303,13 @@ class UserController extends Controller } /** - * 保存聊天室屏蔽与禁音偏好。 + * 保存聊天室屏蔽、禁音与字号偏好。 */ public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse { $user = Auth::user(); $data = $request->validated(); + $existingPreferences = is_array($user->chat_preferences) ? $user->chat_preferences : []; $blockedSystemSenders = collect($data['blocked_system_senders'] ?? []) ->map(function (string $sender): string { // 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。 @@ -324,6 +325,15 @@ class UserController extends Controller 'sound_muted' => (bool) $data['sound_muted'], ]; + // 字号偏好和屏蔽/禁音共用账号配置,旧请求未携带字号时保留原值。 + $fontSize = array_key_exists('font_size', $data) && $data['font_size'] !== null + ? (int) $data['font_size'] + : ($existingPreferences['font_size'] ?? null); + + if ($fontSize !== null) { + $preferences['font_size'] = (int) $fontSize; + } + $user->update([ 'chat_preferences' => $preferences, ]); diff --git a/app/Http/Requests/UpdateChatPreferencesRequest.php b/app/Http/Requests/UpdateChatPreferencesRequest.php index fea6bfe..a10f96d 100644 --- a/app/Http/Requests/UpdateChatPreferencesRequest.php +++ b/app/Http/Requests/UpdateChatPreferencesRequest.php @@ -2,7 +2,7 @@ /** * 文件功能:聊天室偏好设置验证器 - * 负责校验用户提交的屏蔽播报与禁音配置。 + * 负责校验用户提交的屏蔽播报、禁音与聊天室字号配置。 */ namespace App\Http\Requests; @@ -12,7 +12,7 @@ use Illuminate\Validation\Rule; /** * 聊天室偏好设置验证器 - * 仅允许提交白名单内的屏蔽项与布尔型禁音状态。 + * 仅允许提交白名单内的屏蔽项、布尔型禁音状态与合法字号。 */ class UpdateChatPreferencesRequest extends FormRequest { @@ -38,6 +38,7 @@ class UpdateChatPreferencesRequest extends FormRequest Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子', '五子棋', '老虎机', '双色球彩票']), ], 'sound_muted' => ['required', 'boolean'], + 'font_size' => ['nullable', 'integer', 'min:10', 'max:30'], ]; } @@ -53,6 +54,9 @@ class UpdateChatPreferencesRequest extends FormRequest 'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。', 'sound_muted.required' => '请传入禁音状态。', 'sound_muted.boolean' => '禁音状态格式无效。', + 'font_size.integer' => '聊天室字号格式无效。', + 'font_size.min' => '聊天室字号不能小于 10。', + 'font_size.max' => '聊天室字号不能大于 30。', ]; } } diff --git a/app/Services/BaccaratLossCoverService.php b/app/Services/BaccaratLossCoverService.php index 3c8d5e0..31839c4 100644 --- a/app/Services/BaccaratLossCoverService.php +++ b/app/Services/BaccaratLossCoverService.php @@ -400,7 +400,8 @@ class BaccaratLossCoverService } if ($compensableCount > 0) { - $button = ''; + // 聊天消息内的按钮使用相对字号,跟随用户在底部工具栏选择的聊天字号。 + $button = ''; $content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 {$compensableCount} 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}。{$button}"; } else { $content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。"; @@ -446,7 +447,7 @@ class BaccaratLossCoverService $formattedAmount = number_format($amount); $button = $event->status === 'claimable' - ? ' ' + ? ' ' : ''; // 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。 diff --git a/app/Services/FishingService.php b/app/Services/FishingService.php index 3b72759..6b46c11 100644 --- a/app/Services/FishingService.php +++ b/app/Services/FishingService.php @@ -94,10 +94,11 @@ class FishingService $promoTag = ''; if (! $isAi) { $autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user); + // 公屏消息内的促销标签使用相对字号,避免覆盖用户在聊天室选择的字号。 $promoTag = $autoFishingMinutesLeft > 0 ? ' 🎣 自动钓鱼卡' : ''; } diff --git a/public/css/chat.css b/public/css/chat.css index c31f6d8..38d25fe 100644 --- a/public/css/chat.css +++ b/public/css/chat.css @@ -216,7 +216,7 @@ a:hover { } .msg-line .msg-time { - font-size: 9px; + font-size: 0.72em; color: #999; } @@ -245,7 +245,7 @@ a:hover { .msg-line.sys-msg { color: #cc0000; text-align: center; - font-size: 9pt; + font-size: 0.9em; } /* ── 底部输入工具栏 ─────────────────────────────── */ diff --git a/resources/css/chat.css b/resources/css/chat.css index 5cf8322..f6867eb 100644 --- a/resources/css/chat.css +++ b/resources/css/chat.css @@ -240,7 +240,7 @@ a:hover { } .msg-line .msg-time { - font-size: 9px; + font-size: 0.72em; color: #999; } @@ -269,7 +269,7 @@ a:hover { .msg-line.sys-msg { color: #cc0000; text-align: center; - font-size: 9pt; + font-size: 0.9em; } /* ── 底部输入工具栏 ─────────────────────────────── */ diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index bdf9a79..e3b9fae 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -216,7 +216,7 @@ import { initChatImageLightboxEvents, closeChatImageLightbox, openChatImageLight import { bindRoomStatusControls, normalizeRoomStatus, renderRoomStatusRow, renderRoomsOnlineStatus, renderRoomsOnlineStatusToContainer, resolveRoomUrl } from "./chat-room/rooms.js"; import { bindChatRightPanelControls } from "./chat-room/right-panel.js"; import { bindChatImageUploadControl } from "./chat-room/image-upload.js"; -import { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; +import { applyFontSize, bindChatFontSizeControl, CHAT_DEFAULT_FONT_SIZE, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js"; import { bindChatBanner } from "./chat-room/banner.js"; import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js"; @@ -461,6 +461,7 @@ if (typeof window !== "undefined") { sendToChatBot, applyFontSize, bindChatFontSizeControl, + CHAT_DEFAULT_FONT_SIZE, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize, bindChatImageUploadControl, diff --git a/resources/js/chat-room/chat-events.js b/resources/js/chat-room/chat-events.js index f4dddce..9d92f79 100644 --- a/resources/js/chat-room/chat-events.js +++ b/resources/js/chat-room/chat-events.js @@ -8,6 +8,7 @@ import { enqueueChatMessage } from "./message-renderer.js"; // ── 事件注册标记 ── let chatEventsBound = false; let chatWebSocketInitRetryTimer = null; +const GOMOKU_INVITE_BUTTON_FONT_SIZE = "0.82em"; // ── 辅助函数 ── function csrf() { @@ -235,13 +236,13 @@ function setupGomokuInviteListener() { const acceptBtn = isSelf ? `` : ``; diff --git a/resources/js/chat-room/fishing.js b/resources/js/chat-room/fishing.js index a984a6f..9418a94 100644 --- a/resources/js/chat-room/fishing.js +++ b/resources/js/chat-room/fishing.js @@ -10,6 +10,8 @@ let autoFishing = false; let autoFishCooldownTimer = null; let autoFishCooldownCountdown = null; let fishingCastPending = false; +const FISHING_MESSAGE_META_FONT_SIZE = "0.78em"; +const FISHING_MESSAGE_BODY_FONT_SIZE = "1em"; /** * 读取 CSRF Token。 @@ -432,6 +434,13 @@ export async function startFishing() { return; } + if (autoFishing && response.status === 409) { + // 多标签页或重复自动抛竿时,后端会保留先到的 token,当前页等待后再接管。 + appendFishingMessage(`【钓鱼】${escapeHtml(data.message || "已有钓鱼正在进行,稍后自动重试。")}(${timeText()})`); + startAutoFishingCooldown(Math.max(1, Number(data.retry_after) || 5)); + return; + } + window.chatDialog?.alert?.(data.message || "钓鱼失败", "操作失败", "#cc4444"); setFishingButton("🎣 钓鱼", false); return; @@ -460,7 +469,7 @@ export async function startFishing() { if (data.auto_fishing) { showAutoFishStopButton(0); updateAutoFishStopButtonCountdown(0, "自动收竿中 · 可拖动"); - appendFishingMessage(`🎣 自动钓鱼卡生效!自动收竿中... (剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)`); + appendFishingMessage(`🎣 自动钓鱼卡生效!自动收竿中... (剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)`); fishingReelTimeout = window.setTimeout(() => { removeBobber(); void reelFish(); @@ -468,7 +477,7 @@ export async function startFishing() { return; } - appendFishingMessage('🐟 鱼上钩了!快点击屏幕上的浮漂!'); + appendFishingMessage(`🐟 鱼上钩了!快点击屏幕上的浮漂!`); setFishingButton("🎣 点击浮漂!", true); bobber.addEventListener("click", () => { removeBobber(); @@ -530,7 +539,7 @@ export async function reelFish() { 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})` + + ` (经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0})` + `(${timeText()})`, ); diff --git a/resources/js/chat-room/font-size.js b/resources/js/chat-room/font-size.js index e630963..52d10d4 100644 --- a/resources/js/chat-room/font-size.js +++ b/resources/js/chat-room/font-size.js @@ -1,17 +1,69 @@ // 聊天室字号偏好控制,保留旧的 localStorage key 以兼容已有用户设置。 export const CHAT_FONT_SIZE_STORAGE_KEY = "chat_font_size"; +export const CHAT_DEFAULT_FONT_SIZE = 13; +export const CHAT_FONT_SIZE_MIN = 10; +export const CHAT_FONT_SIZE_MAX = 30; let fontSizeEventsBound = false; /** - * 应用字号到聊天消息窗口,并保存到 localStorage。 + * 规整聊天室字号,过滤非法或越界的旧缓存值。 + * + * @param {unknown} size 字号大小 + * @returns {number|null} + */ +export function normalizeChatFontSize(size) { + const px = Number.parseInt(String(size ?? ""), 10); + + if (Number.isNaN(px) || px < CHAT_FONT_SIZE_MIN || px > CHAT_FONT_SIZE_MAX) { + return null; + } + + return px; +} + +/** + * 同步底部输入框上方工具按钮字号。 + * + * @param {number} px 用户选择的聊天字号 + * @returns {void} + */ +function applyInputToolbarFontSize(px) { + const toolbarRow = document.querySelector("#chat-form > .input-row"); + if (!(toolbarRow instanceof HTMLElement)) { + return; + } + + const fontSize = `${px}px`; + toolbarRow.style.fontSize = fontSize; + toolbarRow.style.fontFamily = "inherit"; + toolbarRow.style.lineHeight = "1.2"; + toolbarRow.querySelectorAll([ + ":scope > label", + ":scope > label select", + ":scope > label input", + ":scope > button", + ":scope > div > button", + "#feature-menu button", + "#admin-menu button", + ].join(",")).forEach((control) => { + control.style.fontFamily = "inherit"; + control.style.fontSize = "inherit"; + control.style.lineHeight = "1.2"; + control.style.fontWeight = "400"; + }); +} + +/** + * 应用字号到聊天消息窗口和输入栏工具按钮,并保存到 localStorage。 * * @param {string|number} size 字号大小 + * @param {{syncContext?:boolean}} options 同步选项 * @returns {boolean} */ -export function applyFontSize(size) { - const px = Number.parseInt(size, 10); - if (Number.isNaN(px) || px < 10 || px > 30) { +export function applyFontSize(size, options = {}) { + const px = normalizeChatFontSize(size); + if (px === null) { return false; } @@ -23,8 +75,15 @@ export function applyFontSize(size) { if (privateContainer) { privateContainer.style.fontSize = `${px}px`; } + applyInputToolbarFontSize(px); localStorage.setItem(CHAT_FONT_SIZE_STORAGE_KEY, String(px)); + if (options.syncContext !== false && window.chatContext && typeof window.chatContext === "object") { + window.chatContext.chatPreferences = { + ...(window.chatContext.chatPreferences || {}), + font_size: px, + }; + } const selector = document.getElementById("font_size_select"); if (selector) { @@ -35,14 +94,16 @@ export function applyFontSize(size) { } /** - * 从 localStorage 恢复已保存的聊天室字号。 + * 从账号偏好或 localStorage 恢复已保存的聊天室字号。 * * @returns {boolean} */ export function restoreChatFontSize() { - const saved = localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY); + const serverFontSize = normalizeChatFontSize(window.chatContext?.chatPreferences?.font_size); + const localFontSize = normalizeChatFontSize(localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY)); + const saved = serverFontSize ?? localFontSize ?? CHAT_DEFAULT_FONT_SIZE; - return saved ? applyFontSize(saved) : false; + return applyFontSize(saved, { syncContext: serverFontSize !== null }); } /** @@ -61,6 +122,8 @@ export function bindChatFontSizeControl() { return; } - applyFontSize(event.target.value); + if (applyFontSize(event.target.value)) { + void window.saveChatPreferences?.(); + } }); } diff --git a/resources/js/chat-room/message-renderer.js b/resources/js/chat-room/message-renderer.js index 1e4cb4c..461e933 100644 --- a/resources/js/chat-room/message-renderer.js +++ b/resources/js/chat-room/message-renderer.js @@ -23,6 +23,14 @@ import { // ── 游戏标签判断 ── const GAME_LABEL_PREFIXES = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"]; +const CHAT_NOTICE_CHIP_FONT_SIZE = "0.82em"; +const CHAT_NOTICE_META_FONT_SIZE = "0.72em"; +const CHAT_NOTICE_BUTTON_FONT_SIZE = "0.82em"; +const CHAT_NOTICE_BODY_FONT_SIZE = "1em"; +const CHAT_NOTICE_ICON_FONT_SIZE = "1.08em"; +const CHAT_NOTICE_LARGE_ICON_FONT_SIZE = "1.35em"; +const CHAT_NOTICE_DECOR_ICON_FONT_SIZE = "4.25em"; + function isGameLabel(name) { if (GAME_LABEL_PREFIXES.some((p) => name.startsWith(p))) return true; if (name.includes(" ")) return true; @@ -60,7 +68,7 @@ function parseBracketUsers(content, color = "#000099") { * 构建统一的猜谜活动标题与题型标签。 */ function buildGameLabelChipHtml(label, accentColor) { - return `${escapeHtml(label)}`; + return `${escapeHtml(label)}`; } /** @@ -115,19 +123,19 @@ function buildRedPacketAnnouncementHtml(msg, timeStr) { .trim(); const summary = escapeHtml(textOnlyContent); - const actionButtonHtml = ``; + const actionButtonHtml = ``; return `