diff --git a/app/Events/UserKicked.php b/app/Events/UserKicked.php index 7f791c6..ee3b314 100644 --- a/app/Events/UserKicked.php +++ b/app/Events/UserKicked.php @@ -3,6 +3,8 @@ /** * 文件功能:用户被踢出房间广播事件 * + * 管理员踢出/冻结用户时触发,前端监听后强制该用户跳转至大厅。 + * * @author ChatRoom Laravel * * @version 1.0.0 @@ -21,18 +23,20 @@ class UserKicked implements ShouldBroadcast use Dispatchable, InteractsWithSockets, SerializesModels; /** - * Create a new event instance. + * 构造函数 * * @param int $roomId 房间ID * @param string $username 被踢出的用户昵称 + * @param string $reason 踢出原因 */ public function __construct( public readonly int $roomId, public readonly string $username, + public readonly string $reason = '', ) {} /** - * Get the channels the event should broadcast on. + * 广播频道 * * @return array */ @@ -44,7 +48,7 @@ class UserKicked implements ShouldBroadcast } /** - * 获取广播时的数据 + * 广播数据 * * @return array */ @@ -52,7 +56,7 @@ class UserKicked implements ShouldBroadcast { return [ 'username' => $this->username, - 'message' => "用户 [{$this->username}] 已被踢出聊天室。", + 'reason' => $this->reason, ]; } } diff --git a/app/Events/UserMuted.php b/app/Events/UserMuted.php index d933409..358df97 100644 --- a/app/Events/UserMuted.php +++ b/app/Events/UserMuted.php @@ -31,6 +31,7 @@ class UserMuted implements ShouldBroadcast public readonly int $roomId, public readonly string $username, public readonly int $muteTime, + public readonly string $message = '', ) {} /** @@ -52,9 +53,9 @@ class UserMuted implements ShouldBroadcast */ public function broadcastWith(): array { - $statusMessage = $this->muteTime > 0 - ? "用户 [{$this->username}] 已被系统封口 {$this->muteTime} 次发言时间。" - : "用户 [{$this->username}] 已被解除封口。"; + $statusMessage = $this->message ?: ($this->muteTime > 0 + ? "用户 [{$this->username}] 已被禁言 {$this->muteTime} 分钟。" + : "用户 [{$this->username}] 已被解除禁言。"); return [ 'username' => $this->username, diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php new file mode 100644 index 0000000..0efd26a --- /dev/null +++ b/app/Http/Controllers/AdminCommandController.php @@ -0,0 +1,355 @@ +validate([ + 'username' => 'required|string', + 'room_id' => 'required|integer', + 'reason' => 'nullable|string|max:200', + ]); + + $admin = Auth::user(); + $targetUsername = $request->input('username'); + $roomId = $request->input('room_id'); + $reason = $request->input('reason', '请注意言行'); + + // 权限检查 + if (! $this->canManage($admin, $targetUsername)) { + return response()->json(['status' => 'error', 'message' => '权限不足'], 403); + } + + // 广播警告消息 + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => "⚠️ 管理员 {$admin->username} 警告 {$targetUsername}:{$reason}", + 'is_secret' => false, + 'font_color' => '#dc2626', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + + return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]); + } + + /** + * 踢出用户(=T 理由) + * + * 将目标用户从聊天室踢出,清除其 Redis 在线状态。 + * + * @param Request $request 请求对象,需包含 username, room_id, reason + * @return JsonResponse 操作结果 + */ + public function kick(Request $request): JsonResponse + { + $request->validate([ + 'username' => 'required|string', + 'room_id' => 'required|integer', + 'reason' => 'nullable|string|max:200', + ]); + + $admin = Auth::user(); + $targetUsername = $request->input('username'); + $roomId = $request->input('room_id'); + $reason = $request->input('reason', '违反聊天室规则'); + + if (! $this->canManage($admin, $targetUsername)) { + return response()->json(['status' => 'error', 'message' => '权限不足'], 403); + } + + // 从 Redis 在线列表移除 + $this->chatState->userLeave($roomId, $targetUsername); + + // 广播踢出消息(通知前端强制该用户跳转) + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => "🚫 管理员 {$admin->username} 已将 {$targetUsername} 踢出聊天室。原因:{$reason}", + 'is_secret' => false, + 'font_color' => '#dc2626', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + + // 广播踢出事件(前端监听后强制跳转) + broadcast(new \App\Events\UserKicked($roomId, $targetUsername, $reason)); + + return response()->json(['status' => 'success', 'message' => "已踢出 {$targetUsername}"]); + } + + /** + * 禁言用户(=B 分钟数) + * + * 使用 Redis TTL 自动过期机制,禁止用户发言指定分钟数。 + * + * @param Request $request 请求对象,需包含 username, room_id, duration + * @return JsonResponse 操作结果 + */ + public function mute(Request $request): JsonResponse + { + $request->validate([ + 'username' => 'required|string', + 'room_id' => 'required|integer', + 'duration' => 'required|integer|min:1|max:1440', + ]); + + $admin = Auth::user(); + $targetUsername = $request->input('username'); + $roomId = $request->input('room_id'); + $duration = $request->input('duration'); + + if (! $this->canManage($admin, $targetUsername)) { + return response()->json(['status' => 'error', 'message' => '权限不足'], 403); + } + + // 设置 Redis 禁言标记,TTL 自动过期 + $muteKey = "mute:{$roomId}:{$targetUsername}"; + Redis::setex($muteKey, $duration * 60, now()->toDateTimeString()); + + // 广播禁言消息 + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => "🔇 管理员 {$admin->username} 已将 {$targetUsername} 禁言 {$duration} 分钟。", + 'is_secret' => false, + 'font_color' => '#dc2626', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + + // 广播禁言事件(前端禁用输入框) + broadcast(new \App\Events\UserMuted($roomId, $targetUsername, $duration, "管理员 {$admin->username} 已将您禁言 {$duration} 分钟")); + + return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]); + } + + /** + * 冻结用户账号(=Y 理由) + * + * 将用户账号状态设为冻结,禁止登录。 + * + * @param Request $request 请求对象,需包含 username, reason + * @return JsonResponse 操作结果 + */ + public function freeze(Request $request): JsonResponse + { + $request->validate([ + 'username' => 'required|string', + 'room_id' => 'required|integer', + 'reason' => 'nullable|string|max:200', + ]); + + $admin = Auth::user(); + $targetUsername = $request->input('username'); + $roomId = $request->input('room_id'); + $reason = $request->input('reason', '违反聊天室规则'); + + if (! $this->canManage($admin, $targetUsername)) { + return response()->json(['status' => 'error', 'message' => '权限不足'], 403); + } + + // 冻结用户账号(将等级设为 -1 表示冻结) + $target = User::where('username', $targetUsername)->first(); + if (! $target) { + return response()->json(['status' => 'error', 'message' => '用户不存在'], 404); + } + $target->user_level = -1; + $target->save(); + + // 从所有房间移除 + $rooms = $this->chatState->getUserRooms($targetUsername); + foreach ($rooms as $rid) { + $this->chatState->userLeave($rid, $targetUsername); + } + + // 广播冻结消息 + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => "🧊 管理员 {$admin->username} 已冻结 {$targetUsername} 的账号。原因:{$reason}", + 'is_secret' => false, + 'font_color' => '#dc2626', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + + // 广播踢出事件 + broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}")); + + return response()->json(['status' => 'success', 'message' => "已冻结 {$targetUsername} 的账号"]); + } + + /** + * 查看用户私信(=S) + * + * 管理员查看指定用户最近的悄悄话记录。 + * + * @param string $username 目标用户名 + * @return JsonResponse 私信记录列表 + */ + public function viewWhispers(string $username): JsonResponse + { + $admin = Auth::user(); + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + if ($admin->user_level < $superLevel) { + return response()->json(['status' => 'error', 'message' => '仅站长可查看私信'], 403); + } + + // 查询最近 50 条悄悄话(发送或接收) + $messages = Message::where('is_secret', true) + ->where(function ($q) use ($username) { + $q->where('from_user', $username) + ->orWhere('to_user', $username); + }) + ->orderByDesc('id') + ->limit(50) + ->get(['id', 'from_user', 'to_user', 'content', 'sent_at']); + + return response()->json([ + 'status' => 'success', + 'username' => $username, + 'messages' => $messages, + ]); + } + + /** + * 站长公屏讲话 + * + * 站长发送全聊天室公告,以特殊样式显示。 + * + * @param Request $request 请求对象,需包含 content, room_id + * @return JsonResponse 操作结果 + */ + public function announce(Request $request): JsonResponse + { + $request->validate([ + 'content' => 'required|string|max:500', + 'room_id' => 'required|integer', + ]); + + $admin = Auth::user(); + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + if ($admin->user_level < $superLevel) { + return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403); + } + + $roomId = $request->input('room_id'); + $content = $request->input('content'); + + // 广播站长公告 + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统公告', + 'to_user' => '大家', + 'content' => "📢 站长 {$admin->username} 讲话:{$content}", + 'is_secret' => false, + 'font_color' => '#b91c1c', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + + return response()->json(['status' => 'success', 'message' => '公告已发送']); + } + + /** + * 权限检查:管理员是否可管理目标用户 + * + * 管理员等级必须高于目标用户等级,且不能操作自己。 + * + * @param User $admin 管理员用户 + * @param string $targetUsername 目标用户名 + * @return bool 是否有权限 + */ + private function canManage(User $admin, string $targetUsername): bool + { + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + // 必须是管理员(达到踢人等级) + $kickLevel = (int) Sysparam::getValue('level_kick', '5'); + if ($admin->user_level < $kickLevel) { + return false; + } + + // 不能操作自己 + if ($admin->username === $targetUsername) { + return false; + } + + // 目标用户等级必须低于操作者 + $target = User::where('username', $targetUsername)->first(); + if ($target && $target->user_level >= $admin->user_level) { + return false; + } + + return true; + } +} diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 0f21477..4059674 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -94,44 +94,30 @@ userInfo: {}, isMuting: false, muteDuration: 5, + showWhispers: false, + whisperList: [], + showAnnounce: false, + announceText: '', async fetchUser(username) { try { const res = await fetch('/user/' + encodeURIComponent(username)); - this.userInfo = await res.json(); - this.showUserModal = true; - this.isMuting = false; - } catch (e) { - alert('获取资料失败'); - } + const data = await res.json(); + if (data.status === 'success') { + this.userInfo = data.data; + this.showUserModal = true; + this.isMuting = false; + this.showWhispers = false; + this.whisperList = []; + } + } catch (e) { console.error(e); } }, async kickUser() { - if (!confirm('确定要将 ' + this.userInfo.username + ' 踢出房间吗?')) return; + const reason = prompt('踢出原因(可留空):', '违反聊天室规则'); + if (reason === null) return; try { - const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/kick', { - method: 'POST', - headers: { - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: JSON.stringify({ room_id: window.chatContext.roomId }) - }); - const data = await res.json(); - if (data.status === 'success') { - this.showUserModal = false; - } else { - alert('操作失败:' + data.message); - } - } catch (e) { - alert('网络异常'); - } - }, - - async muteUser() { - try { - const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/mute', { + const res = await fetch('/command/kick', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), @@ -139,20 +125,133 @@ 'Accept': 'application/json' }, body: JSON.stringify({ + username: this.userInfo.username, + room_id: window.chatContext.roomId, + reason: reason || '违反聊天室规则' + }) + }); + const data = await res.json(); + if (data.status === 'success') { + this.showUserModal = false; + } else { + alert('操作失败:' + data.message); + } + } catch (e) { alert('网络异常'); } + }, + + async muteUser() { + try { + const res = await fetch('/command/mute', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + username: this.userInfo.username, room_id: window.chatContext.roomId, duration: this.muteDuration }) }); const data = await res.json(); if (data.status === 'success') { - alert(data.message); this.showUserModal = false; } else { alert('操作失败:' + data.message); } - } catch (e) { - alert('网络异常'); - } + } catch (e) { alert('网络异常'); } + }, + + async warnUser() { + const reason = prompt('警告原因:', '请注意言行'); + if (reason === null) return; + try { + const res = await fetch('/command/warn', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + username: this.userInfo.username, + room_id: window.chatContext.roomId, + reason: reason || '请注意言行' + }) + }); + const data = await res.json(); + if (data.status === 'success') { + this.showUserModal = false; + } else { + alert('操作失败:' + data.message); + } + } catch (e) { alert('网络异常'); } + }, + + async freezeUser() { + if (!confirm('确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录!')) return; + const reason = prompt('冻结原因:', '严重违规'); + if (reason === null) return; + try { + const res = await fetch('/command/freeze', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + username: this.userInfo.username, + room_id: window.chatContext.roomId, + reason: reason || '严重违规' + }) + }); + const data = await res.json(); + if (data.status === 'success') { + this.showUserModal = false; + } else { + alert('操作失败:' + data.message); + } + } catch (e) { alert('网络异常'); } + }, + + async loadWhispers() { + try { + const res = await fetch('/command/whispers/' + encodeURIComponent(this.userInfo.username)); + const data = await res.json(); + if (data.status === 'success') { + this.whisperList = data.messages; + this.showWhispers = true; + } else { + alert(data.message); + } + } catch (e) { alert('网络异常'); } + }, + + async sendAnnounce() { + if (!this.announceText.trim()) return; + try { + const res = await fetch('/command/announce', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + content: this.announceText, + room_id: window.chatContext.roomId, + }) + }); + const data = await res.json(); + if (data.status === 'success') { + this.announceText = ''; + this.showAnnounce = false; + } else { + alert(data.message); + } + } catch (e) { alert('网络异常'); } } }"> - {{-- 操作按钮 --}} + {{-- 普通操作按钮 --}} - @@ -245,7 +382,8 @@ 当前选中: - {{ $user->usersf ?: '未设置' }} + {{ $user->usersf ?: '未设置' }} diff --git a/resources/views/chat/partials/input-bar.blade.php b/resources/views/chat/partials/input-bar.blade.php index 95fd341..9d115f9 100644 --- a/resources/views/chat/partials/input-bar.blade.php +++ b/resources/views/chat/partials/input-bar.blade.php @@ -90,6 +90,13 @@ + + @if ($user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100')) + + @endif + diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 799ab98..49e716b 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -415,7 +415,7 @@ window.addEventListener('chat:kicked', (e) => { if (e.detail.username === window.chatContext.username) { - alert("您已被管理员踢出房间!"); + alert("您已被管理员踢出房间!" + (e.detail.reason ? "\n原因:" + e.detail.reason : "")); window.location.href = "{{ route('rooms.index') }}"; } }); @@ -632,6 +632,34 @@ } } + // ── 站长公屏讲话 ───────────────────────────────────── + async function promptAnnounceMessage() { + const content = prompt('请输入公屏讲话内容:'); + if (!content || !content.trim()) return; + + try { + const res = await fetch('/command/announce', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute( + 'content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + content: content.trim(), + room_id: window.chatContext.roomId, + }) + }); + const data = await res.json(); + if (!res.ok || data.status !== 'success') { + alert(data.message || '发送失败'); + } + } catch (e) { + alert('发送失败:' + e.message); + } + } + // ── 滚屏开关 ───────────────────────────────────── function toggleAutoScroll() { autoScroll = !autoScroll; diff --git a/routes/web.php b/routes/web.php index 5ae13c1..59c0720 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { // ---- AI 聊天机器人 ---- Route::post('/chatbot/chat', [ChatBotController::class, 'chat'])->name('chatbot.chat'); Route::post('/chatbot/clear', [ChatBotController::class, 'clearContext'])->name('chatbot.clear'); + + // ---- 管理员命令(聊天室内实时操作)---- + Route::post('/command/warn', [AdminCommandController::class, 'warn'])->name('command.warn'); + Route::post('/command/kick', [AdminCommandController::class, 'kick'])->name('command.kick'); + Route::post('/command/mute', [AdminCommandController::class, 'mute'])->name('command.mute'); + Route::post('/command/freeze', [AdminCommandController::class, 'freeze'])->name('command.freeze'); + Route::get('/command/whispers/{username}', [AdminCommandController::class, 'viewWhispers'])->name('command.whispers'); + Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce'); }); // 强力特权层中间件:同时验证 chat.auth 登录态 和 chat.level:super 特权(superlevel 由 sysparam 配置)