From 50b050c4bc6cfe55c235c7dea2b7b7bc6a8d89e9 Mon Sep 17 00:00:00 2001 From: pllx Date: Wed, 29 Apr 2026 18:27:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E5=AD=97=E5=8F=B7=E5=81=8F=E5=A5=BD=E5=92=8C=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/DailySignInController.php | 3 +- app/Http/Controllers/EarnController.php | 3 +- app/Http/Controllers/FishingController.php | 97 +++++++++++++++---- app/Http/Controllers/UserController.php | 12 ++- .../Requests/UpdateChatPreferencesRequest.php | 8 +- app/Services/BaccaratLossCoverService.php | 5 +- app/Services/FishingService.php | 3 +- public/css/chat.css | 4 +- resources/css/chat.css | 4 +- resources/js/chat-room.js | 3 +- resources/js/chat-room/chat-events.js | 5 +- resources/js/chat-room/fishing.js | 15 ++- resources/js/chat-room/font-size.js | 79 +++++++++++++-- resources/js/chat-room/message-renderer.js | 72 ++++++++------ resources/js/chat-room/preferences-status.js | 21 +++- resources/js/chat-room/riddle-quiz.js | 10 +- tests/Feature/FishingControllerTest.php | 62 ++++++++++-- tests/Feature/UserControllerTest.php | 49 +++++++++- 18 files changed, 363 insertions(+), 92 deletions(-) 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 `
-
${icon}
+
${icon}
${buildGameLabelChipHtml("礼包红包", accentColor)} - ${escapeHtml(typeLabel)} + ${escapeHtml(typeLabel)}
-
+
${summary} - (${timeStr}) + (${timeStr}) ${actionButtonHtml}
@@ -144,7 +152,7 @@ function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") { return ` ${buildGameLabelChipHtml(activityLabel, accentColor)} - ${escapeHtml(typeLabel)} + ${escapeHtml(typeLabel)} `; } @@ -373,7 +381,7 @@ function normalizeSystemGameCardActions(content, meta) { const onclickAttr = onclickMatch ? ` onclick="${escapeHtml(onclickMatch[2])}"` : ""; const safeLabel = String(label || "").trim(); - return ``; + return ``; }); } @@ -391,12 +399,12 @@ function buildSystemGameNotificationHtml(msg, timeStr) { return `
-
${meta.icon}
+
${meta.icon}
${buildGameLabelChipHtml(meta.label, meta.accent)} -
+
${parseBracketUsers(summary, meta.text)} - (${timeStr}) + (${timeStr})
@@ -420,15 +428,15 @@ function buildQuizStartHtml(msg, timeStr) { return `
-
🧩
+
🧩
${buildQuizBadgeHtml(msg)}
-
+
${safeHint} - (${timeStr}) + (${timeStr})
-
+
💰 ${quizMeta.rewardGold} 金币 ⭐ ${quizMeta.rewardExp} 经验
@@ -478,15 +486,15 @@ function buildQuizResultHtml(msg, timeStr) { return `
-
${icon}
+
${icon}
${buildQuizBadgeHtml(msg, badgeColor)}
-
+
${summaryHtml} - (${timeStr}) + (${timeStr})
${isAnsweredResult ? ` -
+
💰 ${quizMeta.rewardGold} 金币 ⭐ ${quizMeta.rewardExp} 经验
@@ -546,7 +554,7 @@ export function buildChatMessageContent(msg, fontColor, textColorClass) { return ` - 🖼️ 图片已过期 + 🖼️ 图片已过期 ${captionHtml} `; @@ -682,16 +690,16 @@ export function appendMessage(msg, renderBatch = null) { html = `
-
${icon}
+
${icon}
- ${typeLabel} - ${levelName} - (${timeStr}) + ${typeLabel} + ${levelName} + (${timeStr})
-
${safeText}
+
${safeText}
-
${icon}
+
${icon}
`; timeStrOverride = true; @@ -716,7 +724,7 @@ export function appendMessage(msg, renderBatch = null) { } } const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8"); - html = `
💬 ${clickablePrefix}${parsedBody} (${timeStr})
`; + html = `
💬 ${clickablePrefix}${parsedBody} (${timeStr})
`; timeStrOverride = true; } else if (SYSTEM_USERS.includes(msg.from_user)) { if (msg.from_user === "系统公告") { @@ -727,7 +735,7 @@ export function appendMessage(msg, renderBatch = null) { div.style.cssText = "background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);"; const parsedContent = parseBracketUsers(msg.content, "#dc2626"); - html = `
${parsedContent} (${timeStr})
`; + html = `
${parsedContent} (${timeStr})
`; timeStrOverride = true; } } else if (msg.from_user === "系统传音") { @@ -748,13 +756,13 @@ export function appendMessage(msg, renderBatch = null) { "background:linear-gradient(135deg,#fff7ed,#fffbeb);border:1px solid rgba(245,158,11,.28);border-left:4px solid #f59e0b;border-radius:12px;padding:8px 12px;margin:4px 0;box-shadow:0 10px 24px rgba(245,158,11,.14);"; html = `
-
📣
+
📣
${buildQuizBadgeHtml(msg, "#d97706")} (${timeStr})
-
${parseBracketUsers(content, "#b45309")}
+
${parseBracketUsers(content, "#b45309")}
`; @@ -792,7 +800,7 @@ export function appendMessage(msg, renderBatch = null) { } else if (msg.is_secret) { if (msg.from_user === "系统") { div.style.cssText = - "background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;"; + "background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;"; html = `📢 系统:${msg.content}`; } else { const fromHtml = clickableUser(msg.from_user, "#cc00cc", nameClass); @@ -1059,7 +1067,7 @@ export function flushQueuedChatMessages() { const notice = document.createElement("div"); notice.className = "msg-line msg-burst-notice"; notice.style.cssText = - "text-align:center;padding:6px 0;margin:4px 0;font-size:12px;color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;"; + `text-align:center;padding:6px 0;margin:4px 0;font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;`; notice.textContent = `⏫ 省略了 ${dropped} 条系统通知`; container.appendChild(notice); } diff --git a/resources/js/chat-room/preferences-status.js b/resources/js/chat-room/preferences-status.js index 3456845..4c2f2c5 100644 --- a/resources/js/chat-room/preferences-status.js +++ b/resources/js/chat-room/preferences-status.js @@ -1,5 +1,7 @@ // 聊天室偏好与每日状态工具,承接从 Blade 内联脚本迁移出的纯数据规整逻辑。 +import { CHAT_FONT_SIZE_STORAGE_KEY, normalizeChatFontSize } from "./font-size.js"; + export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "猜成语", "星海小博士", "百家乐", "跑马", "神秘箱子", "五子棋", "老虎机", "双色球彩票"]; export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders"; export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted"; @@ -12,7 +14,7 @@ let blockMenuEventsBound = false; * * @param {Record|null|undefined} raw * @param {string[]} blockableSystemSenders - * @returns {{blocked_system_senders:string[],sound_muted:boolean}} + * @returns {{blocked_system_senders:string[],sound_muted:boolean,font_size:number|null}} */ export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) { // 服务端或旧本地缓存可能包含已下架发送者,规整时只保留当前白名单。 @@ -23,6 +25,7 @@ export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE return { blocked_system_senders: Array.from(new Set(blocked)), sound_muted: Boolean(raw?.sound_muted), + font_size: normalizeChatFontSize(raw?.font_size), }; } @@ -454,17 +457,19 @@ export function bindBlockMenuControls() { /** * 当前登录账号没有服务端偏好时,判断是否需要迁移旧本地偏好。 * - * @param {{blocked_system_senders?:string[],sound_muted?:boolean}} serverPreferences + * @param {{blocked_system_senders?:string[],sound_muted?:boolean,font_size?:number|null}} serverPreferences * @param {string[]} localBlockedSenders * @param {boolean} localMuted * @returns {boolean} */ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlockedSenders, localMuted) { // 只有服务端尚无偏好时才迁移旧本地设置,避免覆盖已同步的账号配置。 + const localFontSize = normalizeChatFontSize(localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY)); const hasServerPreferences = (serverPreferences?.blocked_system_senders || []).length > 0 - || Boolean(serverPreferences?.sound_muted); + || Boolean(serverPreferences?.sound_muted) + || normalizeChatFontSize(serverPreferences?.font_size) !== null; - return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted); + return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted || localFontSize !== null); } /** @@ -580,13 +585,19 @@ export function resolveBlockedSystemSenderKey(msg) { /** * 构建当前聊天室偏好快照。 * - * @returns {{blocked_system_senders:string[],sound_muted:boolean}} + * @returns {{blocked_system_senders:string[],sound_muted:boolean,font_size:number|null}} */ export function buildChatPreferencesPayload() { const state = window.chatState; + const selector = document.getElementById("font_size_select"); + const fontSize = normalizeChatFontSize(selector?.value) + ?? normalizeChatFontSize(localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY)) + ?? normalizeChatFontSize(window.chatContext?.chatPreferences?.font_size); + return { blocked_system_senders: state ? Array.from(state.blockedSystemSenders) : [], sound_muted: isSoundMuted(), + font_size: fontSize, }; } diff --git a/resources/js/chat-room/riddle-quiz.js b/resources/js/chat-room/riddle-quiz.js index 002dffd..63f43ac 100644 --- a/resources/js/chat-room/riddle-quiz.js +++ b/resources/js/chat-room/riddle-quiz.js @@ -9,6 +9,8 @@ let currentRoundId = 0; let currentRoomId = 0; let currentQuizType = "idiom"; const QUIZ_TYPES = ["idiom", "brain_teaser"]; +const QUIZ_INLINE_BUTTON_FONT_SIZE = "0.82em"; +const QUIZ_INLINE_META_FONT_SIZE = "0.78em"; /** * 兼容新旧字段,提取前端统一使用的猜谜活动回合信息。 @@ -141,7 +143,7 @@ function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp, typeLabel, btn.textContent = "🎯 立即答题"; btn.style.cssText = "display:inline-flex;align-items:center;gap:4px;padding:2px 9px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" + - "color:#fff;border:1px solid #7c3aed;border-radius:999px;font-size:11px;cursor:pointer;" + + `color:#fff;border:1px solid #7c3aed;border-radius:999px;font-size:${QUIZ_INLINE_BUTTON_FONT_SIZE};cursor:pointer;` + "font-weight:700;line-height:1;vertical-align:middle;box-shadow:0 2px 6px rgba(124,58,237,.14);"; return btn; @@ -195,7 +197,7 @@ function syncQuizWinnerLabel(button, winnerUsername = "") { const winnerLabel = existingLabel || document.createElement("span"); winnerLabel.dataset.quizWinnerLabel = String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0"); winnerLabel.textContent = `答对:${winnerUsername}`; - winnerLabel.style.cssText = "margin-left:6px;font-size:11px;line-height:1.2;color:#64748b;font-weight:700;white-space:nowrap;"; + winnerLabel.style.cssText = `margin-left:6px;font-size:${QUIZ_INLINE_META_FONT_SIZE};line-height:1.2;color:#64748b;font-weight:700;white-space:nowrap;`; if (!existingLabel) { button.insertAdjacentElement("afterend", winnerLabel); @@ -216,7 +218,7 @@ export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已 button.style.boxShadow = "none"; button.style.opacity = ".92"; button.style.padding = "2px 9px"; - button.style.fontSize = "11px"; + button.style.fontSize = QUIZ_INLINE_BUTTON_FONT_SIZE; button.style.lineHeight = "1"; button.title = endedText; button.textContent = "已结束"; @@ -242,7 +244,7 @@ function syncQuizAnswerButtons(activeRoundIds) { button.style.boxShadow = "0 2px 6px rgba(124,58,237,.14)"; button.style.opacity = "1"; button.style.padding = "2px 9px"; - button.style.fontSize = "11px"; + button.style.fontSize = QUIZ_INLINE_BUTTON_FONT_SIZE; button.style.lineHeight = "1"; button.title = ""; button.textContent = "🎯 立即答题"; diff --git a/tests/Feature/FishingControllerTest.php b/tests/Feature/FishingControllerTest.php index 08b471a..45b9d54 100644 --- a/tests/Feature/FishingControllerTest.php +++ b/tests/Feature/FishingControllerTest.php @@ -8,10 +8,16 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Redis; use Tests\TestCase; +/** + * 类功能:覆盖钓鱼小游戏抛竿、收竿和令牌校验流程。 + */ class FishingControllerTest extends TestCase { use RefreshDatabase; + /** + * 初始化钓鱼配置并清理 Redis 状态。 + */ protected function setUp(): void { parent::setUp(); @@ -32,7 +38,10 @@ class FishingControllerTest extends TestCase ); } - public function test_can_cast_rod() + /** + * 方法功能:验证用户金币足够时可以正常抛竿并扣除费用。 + */ + public function test_can_cast_rod(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(['jjb' => 10]); @@ -48,7 +57,10 @@ class FishingControllerTest extends TestCase $this->assertEquals(5, $user->fresh()->jjb); } - public function test_cannot_cast_when_on_cooldown() + /** + * 方法功能:验证冷却期间不能重复抛竿。 + */ + public function test_cannot_cast_when_on_cooldown(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(['jjb' => 10]); @@ -62,7 +74,10 @@ class FishingControllerTest extends TestCase ]); } - public function test_cannot_cast_without_enough_gold() + /** + * 方法功能:验证金币不足时不能抛竿。 + */ + public function test_cannot_cast_without_enough_gold(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(['jjb' => 2]); @@ -75,13 +90,15 @@ class FishingControllerTest extends TestCase ]); } - public function test_can_reel_after_waiting() + /** + * 方法功能:验证等待完成后携带正确令牌可以成功收竿。 + */ + public function test_can_reel_after_waiting(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(['jjb' => 10]); $token = 'test-token'; - $waitTime = 0; // Set to 0 so we can test immediately Redis::set("fishing:token:{$user->id}", json_encode([ 'token' => $token, @@ -102,7 +119,10 @@ class FishingControllerTest extends TestCase $this->assertTrue((bool) Redis::exists("fishing:cd:{$user->id}")); } - public function test_cannot_reel_with_invalid_token() + /** + * 方法功能:验证错误令牌不能收竿。 + */ + public function test_cannot_reel_with_invalid_token(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(['jjb' => 10]); @@ -122,4 +142,34 @@ class FishingControllerTest extends TestCase 'status' => 'error', ]); } + + /** + * 方法功能:验证已有未收竿令牌时再次抛竿会恢复原会话且不会重复扣金币。 + */ + public function test_cast_restores_active_token_without_charging_again(): void + { + /** @var \App\Models\User $user */ + $user = User::factory()->create(['jjb' => 10]); + $tokenKey = "fishing:token:{$user->id}"; + $payload = json_encode([ + 'token' => 'active-token', + 'cast_at' => time(), + 'wait_time' => 10, + ]); + + Redis::setex($tokenKey, 20, $payload); + + $response = $this->actingAs($user)->postJson(route('fishing.cast', ['id' => 1])); + + $response->assertStatus(200); + $response->assertJson([ + 'status' => 'success', + 'token' => 'active-token', + 'cost' => 0, + 'restored' => true, + ]); + + $this->assertSame(10, $user->fresh()->jjb); + $this->assertSame($payload, Redis::get($tokenKey)); + } } diff --git a/tests/Feature/UserControllerTest.php b/tests/Feature/UserControllerTest.php index 66ffbcf..15ca701 100644 --- a/tests/Feature/UserControllerTest.php +++ b/tests/Feature/UserControllerTest.php @@ -417,7 +417,7 @@ class UserControllerTest extends TestCase } /** - * 测试可以保存聊天室屏蔽与禁音偏好。 + * 测试可以保存聊天室屏蔽、禁音与字号偏好。 */ public function test_can_update_chat_preferences(): void { @@ -428,6 +428,7 @@ class UserControllerTest extends TestCase $response = $this->actingAs($user)->putJson('/user/chat-preferences', [ 'blocked_system_senders' => ['钓鱼播报', '神秘箱子', '跑马'], 'sound_muted' => true, + 'font_size' => 22, ]); $response->assertOk() @@ -435,15 +436,59 @@ class UserControllerTest extends TestCase ->assertJsonPath('data.blocked_system_senders.0', '钓鱼播报') ->assertJsonPath('data.blocked_system_senders.1', '神秘箱子') ->assertJsonPath('data.blocked_system_senders.2', '跑马') - ->assertJsonPath('data.sound_muted', true); + ->assertJsonPath('data.sound_muted', true) + ->assertJsonPath('data.font_size', 22); $user->refresh(); $this->assertEquals([ 'blocked_system_senders' => ['钓鱼播报', '神秘箱子', '跑马'], 'sound_muted' => true, + 'font_size' => 22, ], $user->chat_preferences); } + /** + * 测试保存屏蔽偏好时未提交字号不会清空已有字号。 + */ + public function test_chat_preferences_keep_existing_font_size_when_missing_from_payload(): void + { + $user = User::factory()->create([ + 'chat_preferences' => [ + 'blocked_system_senders' => [], + 'sound_muted' => false, + 'font_size' => 18, + ], + ]); + + $response = $this->actingAs($user)->putJson('/user/chat-preferences', [ + 'blocked_system_senders' => ['钓鱼播报'], + 'sound_muted' => false, + ]); + + $response->assertOk() + ->assertJsonPath('data.font_size', 18); + + $user->refresh(); + $this->assertSame(18, $user->chat_preferences['font_size']); + } + + /** + * 测试非法聊天室字号会返回校验错误。 + */ + public function test_invalid_chat_font_size_returns_validation_error(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->putJson('/user/chat-preferences', [ + 'blocked_system_senders' => [], + 'sound_muted' => false, + 'font_size' => 31, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('font_size'); + } + /** * 测试猜谜活动新文案会兼容落回旧的屏蔽键。 */