feat: 新增 /拍一拍 功能 + 斜杠命令菜单

- 输入框输入 / 弹出命令菜单,当前支持 /拍一拍
- 选择对象后输入 /拍一拍 发送拍一拍通知
- 所有在线用户屏幕抖动 + 正常聊天样式显示消息
- 命令注册表可扩展,后续新增命令只需 push 到数组
This commit is contained in:
pllx
2026-04-28 22:59:16 +08:00
parent 0dd85879af
commit 495efdf9e0
10 changed files with 613 additions and 0 deletions
+68
View File
@@ -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,
];
}
}
+70
View File
@@ -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,
]);
}
/**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/
+24
View File
@@ -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;
+11
View File
@@ -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;
+13
View File
@@ -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();
+12
View File
@@ -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) {
+154
View File
@@ -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;
+251
View File
@@ -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 };
+5
View File
@@ -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);
+5
View File
@@ -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')