feat: 新增 /拍一拍 功能 + 斜杠命令菜单
- 输入框输入 / 弹出命令菜单,当前支持 /拍一拍 - 选择对象后输入 /拍一拍 发送拍一拍通知 - 所有在线用户屏幕抖动 + 正常聊天样式显示消息 - 命令注册表可扩展,后续新增命令只需 push 到数组
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户"拍一拍"广播事件
|
||||
*
|
||||
* 用户输入 /拍一拍 用户名 后触发,通过 WebSocket 广播给房间内所有用户,
|
||||
* 前端显示 "XXX拍了拍XXX" 消息并触发屏幕抖动动画。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserPat implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $fromUser 拍人的用户
|
||||
* @param string $targetUser 被拍的用户
|
||||
* @param string $displayText 前端展示文本,如 "流星 拍了拍 张三"
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $fromUser,
|
||||
public readonly string $targetUser,
|
||||
public readonly string $displayText,
|
||||
public readonly ?string $fromUserHeadface = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道:向房间内所有在线用户推送
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'from_user' => $this->fromUser,
|
||||
'target_user' => $this->targetUser,
|
||||
'display_text' => $this->displayText,
|
||||
'from_user_headface' => $this->fromUserHeadface,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = '<img src="' + headImgSrc + '" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src=\'/images/headface/1.gif\'">';
|
||||
|
||||
// 可点击用户名(与正常消息一致)
|
||||
const fromHtml = '<span class="msg-user" data-chat-message-user data-u="' + fromUserSafe + '" style="color: #000099; cursor: pointer;">' + fromUserSafe + '</span>';
|
||||
const toHtml = '<span class="msg-user" data-chat-message-user data-u="' + targetUserSafe + '" style="color: #000099; cursor: pointer;">' + targetUserSafe + '</span>';
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg-line";
|
||||
if (fromUserSafe) {
|
||||
div.dataset.fromUser = fromUserSafe;
|
||||
}
|
||||
|
||||
div.innerHTML = headImg + fromHtml + "对" + toHtml + "说:<span class=\"msg-content\" style=\"color: #000000\">👋 我刚拍了拍你</span> <span class=\"msg-time\">(" + timeStr + ")</span>";
|
||||
|
||||
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;
|
||||
@@ -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
|
||||
? `<span style="font-size:11px;color:#64748b;margin-left:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${cmd.description}</span>`
|
||||
: "";
|
||||
|
||||
item.innerHTML =
|
||||
`<span style="font-size:16px;flex:none;">${cmd.icon}</span>` +
|
||||
`<span style="flex:1;min-width:0;display:flex;align-items:baseline;gap:4px;">${nameHtml}${descHtml}</span>`;
|
||||
|
||||
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) +
|
||||
`<b style="color:#4f46e5;">${text.slice(idx, idx + filter.length)}</b>` +
|
||||
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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user