diff --git a/app/Events/UserPat.php b/app/Events/UserPat.php new file mode 100644 index 0000000..7cb9233 --- /dev/null +++ b/app/Events/UserPat.php @@ -0,0 +1,68 @@ + + */ + public function broadcastOn(): array + { + return [ + new PresenceChannel('room.'.$this->roomId), + ]; + } + + /** + * 广播数据 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'from_user' => $this->fromUser, + 'target_user' => $this->targetUser, + 'display_text' => $this->displayText, + 'from_user_headface' => $this->fromUserHeadface, + ]; + } +} diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 647116d..2335920 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -1415,6 +1415,76 @@ class ChatController extends Controller return null; } + /** + * 拍一拍:用户通过 /拍一拍 命令向所选对象发送拍一拍通知。 + */ + public function pat(Request $request, int $id): JsonResponse + { + $user = Auth::user(); + + if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再使用拍一拍。')) { + return $response; + } + + // 0. 检查用户是否被禁言 + $muteKey = "mute:{$id}:{$user->username}"; + if (Redis::exists($muteKey)) { + $ttl = Redis::ttl($muteKey); + $minutes = ceil($ttl / 60); + + return response()->json([ + 'status' => 'error', + 'message' => "您正在禁言中,还需等待约 {$minutes} 分钟。", + ], 403); + } + + $targetUser = $request->input('target_user', ''); + if (empty($targetUser) || $targetUser === '大家') { + return response()->json([ + 'status' => 'error', + 'message' => '请选择一个聊天对象(不能为大家)进行拍一拍。', + ], 422); + } + + // 检查目标是否在线 + $isOnline = Redis::hexists("room:{$id}:users", $targetUser); + if (! $isOnline) { + return response()->json([ + 'status' => 'error', + 'message' => "【{$targetUser}】目前已离开聊天室或不在线。", + ], 200); + } + + // 不能拍自己 + if ($targetUser === $user->username) { + return response()->json([ + 'status' => 'error', + 'message' => '不能拍自己哦~', + ], 422); + } + + // 获取发送者头像 + $headface = $user->usersf ?: '1.gif'; + $headSrc = str_starts_with($headface, 'storage/') ? '/'.$headface : '/images/headface/'.$headface; + + // 构造展示文本 + $displayText = "{$user->username} 拍了拍 {$targetUser}"; + + // 广播到房间 + broadcast(new \App\Events\UserPat( + roomId: $id, + fromUser: $user->username, + targetUser: $targetUser, + displayText: $displayText, + fromUserHeadface: $headSrc, + )); + + return response()->json([ + 'status' => 'success', + 'message' => $displayText, + ]); + } + /** * 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。 */ diff --git a/resources/css/chat.css b/resources/css/chat.css index c31f6d8..5cf8322 100644 --- a/resources/css/chat.css +++ b/resources/css/chat.css @@ -7,6 +7,30 @@ * @version 1.0.0 */ +/* ═══════════════════════════════════════════════════ + 拍一拍:屏幕抖动动画 + ═══════════════════════════════════════════════════ */ +@keyframes chat-shake { + 0%, 100% { transform: translateX(0); } + 10%, 50%, 90% { transform: translateX(-4px); } + 30%, 70% { transform: translateX(4px); } +} + +.chat-shake { + animation: chat-shake 0.4s ease-in-out; +} + +/* ═══════════════════════════════════════════════════ + 斜杠命令菜单 + ═══════════════════════════════════════════════════ */ +.slash-command-menu::-webkit-scrollbar { + width: 6px; +} +.slash-command-menu::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + /* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */ [x-cloak] { display: none !important; diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 5f6b184..7d910f9 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -288,9 +288,18 @@ import { leaveRoom, notifyExpiredLeave, saveExp, startHeartbeat, stopHeartbeat } import { bindToolbarControls, runFeatureShortcut, runToolbarAction } from "./chat-room/toolbar.js"; import { bindChatInitialStateControls } from "./chat-room/initial-state.js"; +// 拍一拍模块 +import "./chat-room/pat.js"; + +// 斜杠命令菜单 +import { bindSlashCommands, registerSlashCommand } from "./chat-room/slash-commands.js"; + if (typeof window !== "undefined") { bindInstantHoverTooltip(); + // 初始化斜杠命令菜单 + bindSlashCommands(); + // 保留聚合入口,懒加载模块通过按需动态导入自动初始化。 window.ChatRoomTools = { // ── 静态核心模块(直接引用) ──────────────── @@ -469,6 +478,7 @@ if (typeof window !== "undefined") { bindWelcomeMenuControls, toggleWelcomeMenu, bindAdminMenuControls, + registerSlashCommand, bindBaccaratEvents, bindBaccaratLossCoverAdminControls, closeAdminBaccaratLossCoverModal, @@ -680,6 +690,7 @@ if (typeof window !== "undefined") { window.loadFeedbackData = loadFeedbackData; window.loadMoreFeedback = loadMoreFeedback; window.bindFeedbackControls = bindFeedbackControls; + window.registerSlashCommand = registerSlashCommand; // ── Alpine 组件(静态导入,Blade 中 x-data 引用时同步可用) ── window.userCardComponent = userCardComponent; diff --git a/resources/js/chat-room/chat-events.js b/resources/js/chat-room/chat-events.js index 9088835..057e481 100644 --- a/resources/js/chat-room/chat-events.js +++ b/resources/js/chat-room/chat-events.js @@ -457,6 +457,19 @@ export function bindChatEvents() { } }); + // chat:pat — 拍一拍事件 + window.addEventListener("chat:pat", (e) => { + const { from_user, target_user, display_text, from_user_headface } = e.detail || {}; + if (!display_text) return; + + if (typeof window.appendPatMessage === "function") { + window.appendPatMessage(display_text, from_user_headface, from_user, target_user); + } + if (typeof window.triggerPatShake === "function") { + window.triggerPatShake(); + } + }); + // Echo 级监听器(延迟绑定,等待 Echo 就绪) document.addEventListener("DOMContentLoaded", () => { setupScreenClearedListener(); diff --git a/resources/js/chat-room/composer.js b/resources/js/chat-room/composer.js index 99c4fc0..3def3c8 100644 --- a/resources/js/chat-room/composer.js +++ b/resources/js/chat-room/composer.js @@ -177,6 +177,18 @@ async function sendMessage(e) { const composerState = collectChatComposerState(); const { contentInput, submitBtn, content, contentRaw, selectedImage, toUser } = composerState; + // 拦截 /拍一拍 命令:使用当前选中的聊天对象 + if (content && typeof window.isPatCommand === "function" && window.isPatCommand(content)) { + if (state) { + state.isSending = false; + state.sendStartedAt = 0; + } + if (typeof window.executePat === "function") { + await window.executePat(); + } + return; + } + if (!content && !selectedImage) { contentInput?.focus(); if (state) { diff --git a/resources/js/chat-room/pat.js b/resources/js/chat-room/pat.js new file mode 100644 index 0000000..6264e79 --- /dev/null +++ b/resources/js/chat-room/pat.js @@ -0,0 +1,154 @@ +// 拍一拍功能模块 +// 拦截输入框中的 /拍一拍 命令,向所选对象发送拍一拍通知并触发屏幕抖动。 + +import { pruneMessageContainer } from "./message-renderer.js"; + +function csrf() { + return document.querySelector('meta[name="csrf-token"]')?.content ?? ""; +} + +/** + * 判断输入是否为 /拍一拍 命令。 + */ +function isPatCommand(text) { + return /^\/拍一拍\s*$/.test(text); +} + +/** + * 获取当前选中的聊天对象。 + */ +function getSelectedTarget() { + const toUserSelect = document.getElementById("to_user"); + if (!toUserSelect) return null; + const val = toUserSelect.value?.trim(); + return val || null; +} + +/** + * 执行拍一拍请求。 + */ +async function executePat() { + const targetUser = getSelectedTarget(); + if (!targetUser || targetUser === "大家") { + window.chatDialog?.alert("请先选择一个聊天对象(不能为大家),再进行拍一拍。", "拍一拍", "#f472b6"); + return false; + } + + const roomId = window.chatContext?.roomId; + if (!roomId) return false; + + try { + const response = await fetch(`/room/${roomId}/pat`, { + method: "POST", + headers: { + "X-CSRF-TOKEN": csrf(), + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ target_user: targetUser }), + }); + + const data = await response.json(); + + if (data.status === "success") { + // 清空输入并触发本机抖动 + const contentInput = document.getElementById("content"); + if (contentInput) { + contentInput.value = ""; + if (typeof window.persistChatDraft === "function") { + window.persistChatDraft(""); + } + contentInput.focus(); + } + triggerPatShake(); + return true; + } + + window.chatDialog?.alert(data.message || "拍一拍失败", "拍一拍", "#f472b6"); + return false; + } catch (error) { + console.error("拍一拍请求失败:", error); + window.chatDialog?.alert("网络错误,拍一拍发送失败。", "拍一拍", "#f472b6"); + return false; + } +} + +/** + * 触发屏幕抖动动画。 + */ +function triggerPatShake() { + const layout = document.querySelector(".chat-layout"); + if (!layout) return; + + layout.classList.remove("chat-shake"); + // 强制回流后重新添加动画 + void layout.offsetWidth; + layout.classList.add("chat-shake"); + + // 动画结束后移除 class + setTimeout(() => { + layout.classList.remove("chat-shake"); + }, 500); +} + +/** + * 添加拍一拍消息到聊天窗口(使用正常聊天样式渲染)。 + */ +function appendPatMessage(displayText, fromUserHeadface, fromUser, targetUser) { + const state = window.chatState; + const container = state?.container; + if (!container) return; + + const now = new Date(); + const timeStr = now.getHours().toString().padStart(2, "0") + ":" + + now.getMinutes().toString().padStart(2, "0") + ":" + + now.getSeconds().toString().padStart(2, "0"); + + const fromUserSafe = fromUser || ""; + const targetUserSafe = targetUser || ""; + + // 获取发送者的在线数据,获取正确头像 + const senderInfo = state.onlineUsers[fromUserSafe]; + const senderHead = (senderInfo && senderInfo.headface) || "1.gif"; + let headImgSrc = senderHead.startsWith("storage/") ? "/" + senderHead : "/images/headface/" + senderHead; + + const headImg = ''; + + // 可点击用户名(与正常消息一致) + const fromHtml = '' + fromUserSafe + ''; + const toHtml = '' + targetUserSafe + ''; + + const div = document.createElement("div"); + div.className = "msg-line"; + if (fromUserSafe) { + div.dataset.fromUser = fromUserSafe; + } + + div.innerHTML = headImg + fromHtml + "对" + toHtml + "说:👋 我刚拍了拍你 (" + timeStr + ")"; + + container.appendChild(div); + pruneMessageContainer(container, 600); + if (state?.autoScroll) { + container.scrollTop = container.scrollHeight; + } + + // 同时在包厢窗口(say2)也显示 + const container2 = state?.container2; + if (container2) { + const div2 = div.cloneNode(true); + container2.appendChild(div2); + pruneMessageContainer(container2, 300); + if (state?.autoScroll) { + container2.scrollTop = container2.scrollHeight; + } + } +} + +// ── 导出 ── +export { isPatCommand, executePat, triggerPatShake, appendPatMessage }; + +// 挂载到 window 供其他模块使用 +window.isPatCommand = isPatCommand; +window.executePat = executePat; +window.triggerPatShake = triggerPatShake; +window.appendPatMessage = appendPatMessage; diff --git a/resources/js/chat-room/slash-commands.js b/resources/js/chat-room/slash-commands.js new file mode 100644 index 0000000..d2fc1f9 --- /dev/null +++ b/resources/js/chat-room/slash-commands.js @@ -0,0 +1,251 @@ +// 斜杠命令菜单模块 +// 输入 / 时弹出可用命令列表,支持键盘/鼠标选择,可扩展的命令注册表。 +// ── 命令注册表(后续新命令只需 push 到此数组)── + +const SLASH_COMMANDS = [ + { + id: "pat", + name: "/拍一拍", + description: "向当前选中的聊天对象发送拍一拍,屏幕会抖动", + icon: "👋", + /** + * 选中命令后执行的填充逻辑。 + * 返回填充后的输入框文本,或 false 由框架自动填入 name。 + */ + fill(input) { + input.value = "/拍一拍"; + window.persistChatDraft?.("/拍一拍"); + }, + }, +]; + +// ── 菜单状态 ── + +let menuElement = null; +let visible = false; +let selectedIndex = 0; +let currentFilter = ""; + +// ── 过滤 ── + +function getFilteredCommands(filter) { + if (!filter || filter === "/") return SLASH_COMMANDS; + const q = filter.toLowerCase(); + return SLASH_COMMANDS.filter((c) => c.id.includes(q) || c.name.includes(q)); +} + +// ── DOM 构建 ── + +function ensureMenu() { + const input = document.getElementById("content"); + const inputRow = input?.closest(".input-row"); + if (!input || !inputRow) return null; + + let menu = document.getElementById("slash-command-menu"); + if (!menu) { + menu = document.createElement("div"); + menu.id = "slash-command-menu"; + menu.className = "slash-command-menu"; + menu.style.cssText = + "display:none;position:absolute;bottom:100%;left:0;z-index:9999;" + + "min-width:380px;max-width:420px;max-height:200px;overflow-y:auto;" + + "background:#fff;border:1px solid #cbd5e1;border-radius:10px;" + + "box-shadow:0 6px 20px rgba(15,23,42,.18);padding:6px 0;"; + inputRow.style.position = "relative"; + inputRow.appendChild(menu); + } + return menu; +} + +function renderMenu(filtered, filter) { + const menu = ensureMenu(); + if (!menu) return; + + menu.innerHTML = ""; + + if (filtered.length === 0) { + const empty = document.createElement("div"); + empty.style.cssText = + "padding:10px 14px;color:#94a3b8;font-size:12px;text-align:center;"; + empty.textContent = "没有匹配的命令"; + menu.appendChild(empty); + return; + } + + filtered.forEach((cmd, i) => { + const item = document.createElement("div"); + item.dataset.index = String(i); + item.style.cssText = + "display:flex;align-items:center;gap:10px;padding:8px 14px;" + + "cursor:pointer;transition:background .1s;white-space:nowrap;" + + (i === selectedIndex ? "background:#eef2ff;" : ""); + + // 高亮匹配文字 + const nameHtml = highlightMatch(cmd.name, filter); + const descHtml = cmd.description + ? `${cmd.description}` + : ""; + + item.innerHTML = + `${cmd.icon}` + + `${nameHtml}${descHtml}`; + + item.addEventListener("mousedown", (e) => { + e.preventDefault(); + selectCommand(i); + }); + item.addEventListener("mouseenter", () => { + selectedIndex = i; + highlightItem(menu, i); + }); + + menu.appendChild(item); + }); +} + +function highlightMatch(text, filter) { + if (!filter || filter === "/") return text; + const idx = text.toLowerCase().indexOf(filter.toLowerCase()); + if (idx === -1) return text; + return ( + text.slice(0, idx) + + `${text.slice(idx, idx + filter.length)}` + + text.slice(idx + filter.length) + ); +} + +function highlightItem(menu, index) { + const items = menu.querySelectorAll("[data-index]"); + items.forEach((el, i) => { + el.style.background = i === index ? "#eef2ff" : ""; + }); +} + +// ── 选择 ── + +function selectCommand(index) { + const filtered = getFilteredCommands(currentFilter); + const cmd = filtered[index]; + if (!cmd) return; + + const input = document.getElementById("content"); + if (!input) return; + + if (typeof cmd.fill === "function") { + cmd.fill(input); + } else { + input.value = cmd.name; + window.persistChatDraft?.(cmd.name); + } + + hideMenu(); + input.focus(); +} + +// ── 显示/隐藏 ── + +function showMenu(filter) { + const menu = ensureMenu(); + if (!menu) return; + + currentFilter = filter; + selectedIndex = 0; + const filtered = getFilteredCommands(filter); + visible = filtered.length > 0; + renderMenu(filtered, filter); + menu.style.display = visible ? "block" : "none"; +} + +function hideMenu() { + const menu = document.getElementById("slash-command-menu"); + if (menu) menu.style.display = "none"; + visible = false; + selectedIndex = 0; + currentFilter = ""; +} + +// ── 事件绑定 ── + +function handleInput(e) { + const input = e.target; + const val = input.value; + + if (val.startsWith("/")) { + // 如果输入值已是完整命令名,不弹出菜单 + const exactMatch = SLASH_COMMANDS.some( + (c) => c.name === val.trim() + ); + if (!exactMatch) { + const filter = val.trim(); + showMenu(filter); + return; + } + } + hideMenu(); +} + +function handleKeydown(e) { + if (!visible) return; + + const filtered = getFilteredCommands(currentFilter); + if (filtered.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1); + highlightItem(ensureMenu(), selectedIndex); + break; + case "ArrowUp": + e.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, 0); + highlightItem(ensureMenu(), selectedIndex); + break; + case "Enter": + if (visible) { + e.preventDefault(); + selectCommand(selectedIndex); + } + break; + case "Escape": + e.preventDefault(); + hideMenu(); + break; + } +} + +function handleDocumentClick(e) { + if (visible) { + const menu = document.getElementById("slash-command-menu"); + const input = document.getElementById("content"); + if (menu && !menu.contains(e.target) && input !== e.target) { + hideMenu(); + } + } +} + +// ── 初始化 ── + +export function bindSlashCommands() { + const input = document.getElementById("content"); + if (!input) { + // 如果 DOM 未就绪,稍后重试 + setTimeout(bindSlashCommands, 200); + return; + } + + // 避免重复绑定 + if (input.dataset.slashBound) return; + input.dataset.slashBound = "1"; + + input.addEventListener("input", handleInput); + input.addEventListener("keydown", handleKeydown); + document.addEventListener("click", handleDocumentClick); +} + +// 允许外部扩展命令列表 +export function registerSlashCommand(cmd) { + SLASH_COMMANDS.push(cmd); +} + +export { SLASH_COMMANDS }; diff --git a/resources/js/chat.js b/resources/js/chat.js index d1fd464..d0d58e8 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -264,6 +264,11 @@ export function initChat(roomId) { console.log("特效播放:", e); window.dispatchEvent(new CustomEvent("chat:effect", { detail: e })); }) + // 监听拍一拍 + .listen("UserPat", (e) => { + console.log("拍一拍:", e); + window.dispatchEvent(new CustomEvent("chat:pat", { detail: e })); + }) // 监听任命公告(礼花 + 隆重弹窗) .listen("AppointmentAnnounced", (e) => { console.log("任命公告:", e); diff --git a/routes/web.php b/routes/web.php index 2a8ea93..b6e03d9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -286,6 +286,11 @@ Route::middleware(['chat.auth'])->group(function () { ->middleware('throttle:chat-send') ->name('chat.send'); + // 拍一拍 + Route::post('/room/{id}/pat', [ChatController::class, 'pat']) + ->middleware('throttle:chat-send') + ->name('chat.pat'); + // 挂机心跳存点 (限制每分钟最多调用 6 次防止挂机脚本滥用) Route::post('/room/{id}/heartbeat', [ChatController::class, 'heartbeat']) ->middleware('throttle:6,1')