修复聊天室字号偏好和游戏通知显示

This commit is contained in:
pllx
2026-04-29 18:27:32 +08:00
parent 6748fbc44e
commit 50b050c4bc
18 changed files with 363 additions and 92 deletions
@@ -185,9 +185,10 @@ class DailySignInController extends Controller
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
}
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
.'background:#ccfbf1;color:#0f766e;font-size:10px;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'background:#ccfbf1;color:#0f766e;font-size:0.78em;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 '
+2 -1
View File
@@ -104,9 +104,10 @@ class EarnController extends Controller
// 6. 广播全服系统消息
if ($roomId > 0) {
// 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
$sysMsg = [
+80 -17
View File
@@ -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,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
*
+11 -1
View File
@@ -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,
]);
@@ -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。',
];
}
}
+3 -2
View File
@@ -400,7 +400,8 @@ class BaccaratLossCoverService
}
if ($compensableCount > 0) {
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>';
// 聊天消息内的按钮使用相对字号,跟随用户在底部工具栏选择的聊天字号。
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>';
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$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'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>'
: '';
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
+2 -1
View File
@@ -94,10 +94,11 @@ class FishingService
$promoTag = '';
if (! $isAi) {
$autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user);
// 公屏消息内的促销标签使用相对字号,避免覆盖用户在聊天室选择的字号。
$promoTag = $autoFishingMinutesLeft > 0
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: '';
}
+2 -2
View File
@@ -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;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
+2 -2
View File
@@ -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;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
+2 -1
View File
@@ -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,
+3 -2
View File
@@ -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
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:all .15s;">
⤴️ 打开面板
</button>`
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
border-radius:12px; background:#336699; color:#fff; font-size:12px;
border-radius:12px; background:#336699; color:#fff; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:opacity .15s;">
⚔️ 接受挑战
</button>`;
+12 -3
View File
@@ -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(`<span style="color:#d97706;">【钓鱼】${escapeHtml(data.message || "已有钓鱼正在进行,稍后自动重试。")}</span><span class="msg-time">(${timeText()})</span>`);
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(`<span style="color:#7c3aed;font-weight:bold;">🎣 自动钓鱼卡生效!自动收竿中... <span style="font-size:10px;opacity:0.7">(剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)</span></span>`);
appendFishingMessage(`<span style="color:#7c3aed;font-weight:bold;">🎣 自动钓鱼卡生效!自动收竿中... <span style="font-size:${FISHING_MESSAGE_META_FONT_SIZE};opacity:0.7">(剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)</span></span>`);
fishingReelTimeout = window.setTimeout(() => {
removeBobber();
void reelFish();
@@ -468,7 +477,7 @@ export async function startFishing() {
return;
}
appendFishingMessage('<span style="color:#d97706;font-weight:bold;font-size:14px;">🐟 鱼上钩了!快点击屏幕上的浮漂!</span>');
appendFishingMessage(`<span style="color:#d97706;font-weight:bold;font-size:${FISHING_MESSAGE_BODY_FONT_SIZE};">🐟 鱼上钩了!快点击屏幕上的浮漂!</span>`);
setFishingButton("🎣 点击浮漂!", true);
bobber.addEventListener("click", () => {
removeBobber();
@@ -530,7 +539,7 @@ export async function reelFish() {
const color = Number(result.exp || 0) >= 0 ? "#16a34a" : "#dc2626";
appendFishingMessage(
`<span style="color:${color};font-weight:bold;">${escapeHtml(result.emoji || "🎣")}【钓鱼结果】</span>${escapeHtml(result.message || "")}` +
` <span style="color:#666;font-size:11px;">(经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0}</span>` +
` <span style="color:#666;font-size:${FISHING_MESSAGE_META_FONT_SIZE};">(经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0}</span>` +
`<span class="msg-time">(${timeText()})</span>`,
);
+71 -8
View File
@@ -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?.();
}
});
}
+40 -32
View File
@@ -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 `<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:11px;font-weight:700;line-height:1;border:1px solid ${accentColor};">${escapeHtml(label)}</span>`;
return `<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor};">${escapeHtml(label)}</span>`;
}
/**
@@ -115,19 +123,19 @@ function buildRedPacketAnnouncementHtml(msg, timeStr) {
.trim();
const summary = escapeHtml(textOnlyContent);
const actionButtonHtml = `<button type="button"${buttonOnclick ? ` onclick="${escapeHtml(buttonOnclick)}"` : ""} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:11px;font-weight:700;line-height:1;border:1px solid ${accentColor};cursor:pointer;box-shadow:none;vertical-align:middle;">${escapeHtml(buttonLabel)}</button>`;
const actionButtonHtml = `<button type="button"${buttonOnclick ? ` onclick="${escapeHtml(buttonOnclick)}"` : ""} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor};cursor:pointer;box-shadow:none;vertical-align:middle;">${escapeHtml(buttonLabel)}</button>`;
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${colorPalette.softBackground};border:1px solid ${colorPalette.softBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${accentColor};display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;box-shadow:0 2px 6px ${colorPalette.softBorder};flex-shrink:0;">${icon}</div>
<div style="width:23px;height:23px;border-radius:7px;background:${accentColor};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${colorPalette.softBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${colorPalette.text};">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex-shrink:0;">
${buildGameLabelChipHtml("礼包红包", accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${colorPalette.chipBackground};color:${colorPalette.chipText};font-size:11px;font-weight:700;line-height:1;border:1px solid ${colorPalette.chipBorder};">${escapeHtml(typeLabel)}</span>
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${colorPalette.chipBackground};color:${colorPalette.chipText};font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${colorPalette.chipBorder};">${escapeHtml(typeLabel)}</span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summary}</span>
<span class="msg-time" style="font-size:10px;color:#94a3b8;">(${timeStr})</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
${actionButtonHtml}
</div>
</div>
@@ -144,7 +152,7 @@ function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
return `
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
${buildGameLabelChipHtml(activityLabel, accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:11px;font-weight:700;line-height:1;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
</span>
`;
}
@@ -373,7 +381,7 @@ function normalizeSystemGameCardActions(content, meta) {
const onclickAttr = onclickMatch ? ` onclick="${escapeHtml(onclickMatch[2])}"` : "";
const safeLabel = String(label || "").trim();
return `<button type="button"${onclickAttr} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:#fff;color:${meta.accent};font-size:11px;font-weight:700;line-height:1;border:1px solid ${meta.accent};cursor:pointer;box-shadow:none;vertical-align:middle;">${safeLabel}</button>`;
return `<button type="button"${onclickAttr} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:#fff;color:${meta.accent};font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${meta.accent};cursor:pointer;box-shadow:none;vertical-align:middle;">${safeLabel}</button>`;
});
}
@@ -391,12 +399,12 @@ function buildSystemGameNotificationHtml(msg, timeStr) {
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${meta.background};border:1px solid ${meta.border};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${meta.accent};display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;box-shadow:0 2px 6px ${meta.border};flex-shrink:0;">${meta.icon}</div>
<div style="width:23px;height:23px;border-radius:7px;background:${meta.accent};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${meta.border};flex-shrink:0;">${meta.icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${meta.text};">
${buildGameLabelChipHtml(meta.label, meta.accent)}
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${parseBracketUsers(summary, meta.text)}</span>
<span class="msg-time" style="font-size:10px;color:#94a3b8;font-weight:600;">(${timeStr})</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;font-weight:600;">(${timeStr})</span>
</div>
</div>
</div>
@@ -420,15 +428,15 @@ function buildQuizStartHtml(msg, timeStr) {
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:linear-gradient(135deg,#f5f3ff,#faf5ff);border:1px solid rgba(124,58,237,.16);box-shadow:0 4px 12px rgba(124,58,237,.07);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;box-shadow:0 2px 6px rgba(124,58,237,.16);flex-shrink:0;">🧩</div>
<div style="width:23px;height:23px;border-radius:7px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px rgba(124,58,237,.16);flex-shrink:0;">🧩</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:#312e81;">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg)}</div>
<div data-quiz-inline-text style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<div data-quiz-inline-text style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${safeHint}</span>
<span class="msg-time" style="font-size:10px;color:#94a3b8;">(${timeStr})</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
<span data-quiz-inline-action-anchor></span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:#6d28d9;font-size:10px;flex-shrink:0;margin-left:auto;">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:#6d28d9;font-size:${CHAT_NOTICE_META_FONT_SIZE};flex-shrink:0;margin-left:auto;">
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
@@ -478,15 +486,15 @@ function buildQuizResultHtml(msg, timeStr) {
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${accentBackground};border:1px solid ${accentBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${iconBackground};display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;box-shadow:0 2px 6px ${accentBorder};flex-shrink:0;">${icon}</div>
<div style="width:23px;height:23px;border-radius:7px;background:${iconBackground};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${accentBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${textColor};">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg, badgeColor)}</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:12px;line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summaryHtml}</span>
<span class="msg-time" style="font-size:10px;color:#94a3b8;">(${timeStr})</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
</div>
${isAnsweredResult ? `
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:${textColor};font-size:10px;flex-shrink:0;margin-left:auto;">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:${textColor};font-size:${CHAT_NOTICE_META_FONT_SIZE};flex-shrink:0;margin-left:auto;">
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px ${isAnsweredResult ? "rgba(124,58,237,.10)" : "rgba(217,119,6,.12)"};white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px ${isAnsweredResult ? "rgba(124,58,237,.10)" : "rgba(217,119,6,.12)"};white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
@@ -546,7 +554,7 @@ export function buildChatMessageContent(msg, fontColor, textColorClass) {
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
@@ -682,16 +690,16 @@ export function appendMessage(msg, renderBatch = null) {
html = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:${CHAT_NOTICE_LARGE_ICON_FONT_SIZE};box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
<span style="font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
<div style="margin-top:4px;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:${CHAT_NOTICE_DECOR_ICON_FONT_SIZE}; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
</div>
`;
timeStrOverride = true;
@@ -716,7 +724,7 @@ export function appendMessage(msg, renderBatch = null) {
}
}
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: ${CHAT_NOTICE_META_FONT_SIZE}; font-weight: normal;">(${timeStr})</span></div>`;
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 = `<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
html = `<div style="font-size: ${CHAT_NOTICE_BODY_FONT_SIZE}; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: ${CHAT_NOTICE_META_FONT_SIZE}; font-weight: 500;">(${timeStr})</span></div>`;
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 = `
<div style="display:flex;align-items:flex-start;gap:10px;">
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_LARGE_ICON_FONT_SIZE};box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#d97706")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:7px;color:#9a3412;font-size:15px;font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
<div style="margin-top:7px;color:#9a3412;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
</div>
</div>
`;
@@ -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 = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} 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);
}
+16 -5
View File
@@ -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<string, unknown>|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,
};
}
+6 -4
View File
@@ -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 = "🎯 立即答题";
+56 -6
View File
@@ -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));
}
}
+47 -2
View File
@@ -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');
}
/**
* 测试猜谜活动新文案会兼容落回旧的屏蔽键。
*/