From 700ab9def4ff5117ebcd20d1d3b9eb152b6876d6 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 1 Mar 2026 00:48:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A5=BD=E5=8F=8B=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=85=A8=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - FriendController:add/remove/status/index 四个接口 - FriendAdded / FriendRemoved 广播事件(私有频道) - channels.php 注册 user.{username} 私有频道鉴权 - routes/web.php 注册好友路由 - ChatController::init() 修复 DutyLog 在 return 后执行的 bug - ChatController::notifyFriendsOnline() 上线时悄悄话通知好友 前端: - user-actions:写私信 → 加好友/删好友按钮(动态状态) - toggleFriend() 方法 + fetchUser 后加载好友状态 - scripts:监听私有频道 FriendAdded/FriendRemoved - showFriendToast() 右下角浮窗通知(5秒自动消失) - global-dialog 加 fdSlideIn 动画 --- app/Events/FriendAdded.php | 59 +++++ app/Events/FriendRemoved.php | 59 +++++ app/Http/Controllers/ChatController.php | 65 ++++- app/Http/Controllers/FriendController.php | 234 ++++++++++++++++++ .../chat/partials/global-dialog.blade.php | 12 + .../views/chat/partials/scripts.blade.php | 58 +++++ .../chat/partials/user-actions.blade.php | 76 +++++- routes/channels.php | 6 + routes/web.php | 6 + 9 files changed, 557 insertions(+), 18 deletions(-) create mode 100644 app/Events/FriendAdded.php create mode 100644 app/Events/FriendRemoved.php create mode 100644 app/Http/Controllers/FriendController.php diff --git a/app/Events/FriendAdded.php b/app/Events/FriendAdded.php new file mode 100644 index 0000000..8d7390e --- /dev/null +++ b/app/Events/FriendAdded.php @@ -0,0 +1,59 @@ +toUsername); + } + + /** + * 广播负载:包含发起人信息,供前端弹窗使用。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'from_username' => $this->fromUsername, + 'to_username' => $this->toUsername, + 'type' => 'friend_added', + ]; + } +} diff --git a/app/Events/FriendRemoved.php b/app/Events/FriendRemoved.php new file mode 100644 index 0000000..4984f6a --- /dev/null +++ b/app/Events/FriendRemoved.php @@ -0,0 +1,59 @@ +toUsername); + } + + /** + * 广播负载:包含发起人信息,供前端弹窗使用。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'from_username' => $this->fromUsername, + 'to_username' => $this->toUsername, + 'type' => 'friend_removed', + ]; + } +} diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 9f38125..9d3fa8d 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -18,6 +18,7 @@ use App\Events\UserLeft; use App\Http\Requests\SendMessageRequest; use App\Jobs\SaveMessageJob; use App\Models\Autoact; +use App\Models\FriendRequest; use App\Models\Gift; use App\Models\PositionDutyLog; use App\Models\Room; @@ -182,17 +183,7 @@ class ChatController extends Controller return $fromUser === $username || $toUser === $username; })); - // 渲染主聊天框架视图 - return view('chat.frame', [ - 'room' => $room, - 'user' => $user, - 'weekEffect' => $this->shopService->getActiveWeekEffect($user), - 'newbieEffect' => $newbieEffect, - 'historyMessages' => $historyMessages, - ]); - - // 最后:如果用户有在职职务,开始记录这次入场的在职登录 - // 此时用户局部变量已初始化,可以安全读取 in_time + // 7. 如果用户有在职職务,开始记录这次入场的在职登录 $activeUP = $user->activePosition; if ($activeUP) { PositionDutyLog::create([ @@ -203,6 +194,58 @@ class ChatController extends Controller 'room_id' => $id, ]); } + + // 8. 好友上线通知:向此房间内在线的好友推送慧慧话 + $this->notifyFriendsOnline($id, $user->username); + + // 渲染主聊天框架视图 + return view('chat.frame', [ + 'room' => $room, + 'user' => $user, + 'weekEffect' => $this->shopService->getActiveWeekEffect($user), + 'newbieEffect' => $newbieEffect, + 'historyMessages' => $historyMessages, + ]); + } + + /** + * 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。 + * + * @param int $roomId 当前房间 ID + * @param string $username 上线的用户名 + */ + private function notifyFriendsOnline(int $roomId, string $username): void + { + // 获取所有把我加为好友的人(他们是将我加为好友的关注者) + $friendUsernames = FriendRequest::where('towho', $username)->pluck('who'); + if ($friendUsernames->isEmpty()) { + return; + } + + // 当前房间在线用户列表 + $onlineUsers = $this->chatState->getRoomUsers($roomId); + + foreach ($friendUsernames as $friendName) { + // 好友就在这个房间里,才发通知 + if (! isset($onlineUsers[$friendName])) { + continue; + } + + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $friendName, + 'content' => "🟢 你的好友 {$username} 上线啊!", + 'is_secret' => true, + 'font_color' => '#16a34a', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + } } /** diff --git a/app/Http/Controllers/FriendController.php b/app/Http/Controllers/FriendController.php new file mode 100644 index 0000000..eff1c62 --- /dev/null +++ b/app/Http/Controllers/FriendController.php @@ -0,0 +1,234 @@ +username) + ->where('towho', $username) + ->exists(); + + // 对方是否也将我加为好友 + $theyAdded = FriendRequest::where('who', $username) + ->where('towho', $me->username) + ->exists(); + + return response()->json([ + 'is_friend' => $iAdded, + 'mutual' => $iAdded && $theyAdded, + ]); + } + + /** + * 添加好友。 + * + * 流程: + * 1. 校验目标用户存在、且不是自己 + * 2. 检查是否已经添加过 + * 3. 写入 friend_requests 记录 + * 4. 广播 FriendAdded 事件通知对方 + * 5. 若对方当前在线(Redis),向对方发送悄悄话 + * + * @param string $username 目标用户名 + */ + public function addFriend(Request $request, string $username): JsonResponse + { + $me = Auth::user(); + + // 不能加自己 + if ($me->username === $username) { + return response()->json(['status' => 'error', 'message' => '不能将自己加为好友'], 422); + } + + // 检查目标用户是否存在 + $target = User::where('username', $username)->first(); + if (! $target) { + return response()->json(['status' => 'error', 'message' => '用户不存在'], 404); + } + + // 是否已添加 + $exists = FriendRequest::where('who', $me->username)->where('towho', $username)->exists(); + if ($exists) { + return response()->json(['status' => 'error', 'message' => '已是好友,无需重复添加'], 422); + } + + // 写入好友关系 + FriendRequest::create([ + 'who' => $me->username, + 'towho' => $username, + 'sub_time' => now(), + ]); + + // 广播给对方(仅对方可见) + broadcast(new FriendAdded($me->username, $username)); + + // 若对方在线,推送聊天区悄悄话 + $this->notifyOnlineUser($username, $me->username, 'added', $request->input('room_id')); + + return response()->json([ + 'status' => 'success', + 'message' => '已成功添加 '.$username.' 为好友 🎉', + ]); + } + + /** + * 删除好友。 + * + * 流程: + * 1. 删除 friend_requests 中「我 → 对方」的记录 + * 2. 广播 FriendRemoved 事件通知对方 + * 3. 若对方在线,向对方发送悄悄话 + * + * @param string $username 目标用户名 + */ + public function removeFriend(Request $request, string $username): JsonResponse + { + $me = Auth::user(); + + $deleted = FriendRequest::where('who', $me->username) + ->where('towho', $username) + ->delete(); + + if (! $deleted) { + return response()->json(['status' => 'error', 'message' => '好友关系不存在'], 404); + } + + // 广播给对方 + broadcast(new FriendRemoved($me->username, $username)); + + // 若对方在线,推送聊天区悄悄话 + $this->notifyOnlineUser($username, $me->username, 'removed', $request->input('room_id')); + + return response()->json([ + 'status' => 'success', + 'message' => '已将 '.$username.' 从好友列表移除', + ]); + } + + /** + * 获取当前用户的好友列表(我添加的 + 对方也添加我的 = 双向好友标记)。 + */ + public function index(): JsonResponse + { + $me = Auth::user(); + + // 我添加的所有人 + $myAdded = FriendRequest::where('who', $me->username)->pluck('towho'); + + // 也把我加了的 + $addedMe = FriendRequest::where('towho', $me->username)->pluck('who'); + + $friends = User::whereIn('username', $myAdded)->get(['username', 'usersf', 'user_level', 'sex'])->map(function ($u) use ($addedMe) { + return [ + 'username' => $u->username, + 'headface' => $u->headface, + 'user_level' => $u->user_level, + 'sex' => $u->sex, + 'mutual' => $addedMe->contains($u->username), // 是否互相添加 + ]; + }); + + return response()->json(['status' => 'success', 'friends' => $friends]); + } + + /** + * 若目标用户在线,向其发送系统悄悄话通知。 + * + * 好友上线/下线使用此方法,不公开广播,只有本人可见。 + * + * @param string $targetUsername 接收通知的用户名 + * @param string $fromUsername 发起操作的用户名 + * @param string $action 'added' | 'removed' | 'online' + * @param int|null $roomId 当前房间 ID(用于推送到对应房间频道) + */ + private function notifyOnlineUser( + string $targetUsername, + string $fromUsername, + string $action, + ?int $roomId = null + ): void { + if (! $roomId) { + return; + } + + // 检查对方是否在该房间在线 + $onlineUsers = $this->chatState->getRoomUsers($roomId); + if (! isset($onlineUsers[$targetUsername])) { + return; + } + + $content = match ($action) { + 'added' => "💚 {$fromUsername} 将你加为好友了!你们现在是好友了 🎉", + 'removed' => "💔 {$fromUsername} 已将你从好友列表移除。", + 'online' => "🟢 你的好友 {$fromUsername} 上线啦!", + default => '', + }; + + if (! $content) { + return; + } + + // 构建系统悄悄话消息 + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $targetUsername, + 'content' => $content, + 'is_secret' => true, + 'font_color' => '#16a34a', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + + $this->chatState->pushMessage($roomId, $msg); + broadcast(new \App\Events\MessageSent($roomId, $msg)); + } +} diff --git a/resources/views/chat/partials/global-dialog.blade.php b/resources/views/chat/partials/global-dialog.blade.php index fb3620d..bd92b5d 100644 --- a/resources/views/chat/partials/global-dialog.blade.php +++ b/resources/views/chat/partials/global-dialog.blade.php @@ -62,6 +62,18 @@ } } + @keyframes fdSlideIn { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + #global-dialog-cancel-btn:hover { background: #e5e7eb !important; } diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 5485efd..05a9512 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -650,6 +650,64 @@ } document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener); + // ── 好友系统私有频道监听(仅本人可见) ──────────────── + /** + * 监听当前用户的私有频道 `user.{username}`, + * 收到 FriendAdded / FriendRemoved 事件时用任务弹窗通知。 + */ + function setupFriendNotification() { + if (!window.Echo || !window.chatContext) { + setTimeout(setupFriendNotification, 500); + return; + } + const myName = window.chatContext.username; + window.Echo.private(`user.${myName}`) + .listen('.FriendAdded', (e) => { + showFriendToast( + `💚 ${e.from_username} 将你加为好友了!`, + '#16a34a' + ); + }) + .listen('.FriendRemoved', (e) => { + showFriendToast( + `💔 ${e.from_username} 已将你从好友列表移除。`, + '#6b7280' + ); + }); + } + document.addEventListener('DOMContentLoaded', setupFriendNotification); + + /** + * 显示好友事件通知浮窗(类似任务弹窗,右下角淡入淡出)。 + * + * @param {string} html 通知内容(支持 HTML) + * @param {string} color 左边框颜色 + */ + function showFriendToast(html, color = '#16a34a') { + const toast = document.createElement('div'); + toast.style.cssText = ` + position: fixed; bottom: 24px; right: 24px; z-index: 999999; + background: #fff; border-left: 4px solid ${color}; + border-radius: 8px; padding: 14px 18px; min-width: 260px; max-width: 320px; + box-shadow: 0 8px 32px rgba(0,0,0,.18); + font-size: 13px; color: #374151; line-height: 1.5; + animation: fdSlideIn .3s ease; cursor: pointer; + `; + toast.innerHTML = ` +
💬 好友通知
+
${html}
+ `; + // 点击关闭 + toast.addEventListener('click', () => toast.remove()); + document.body.appendChild(toast); + // 5秒后自动消失 + setTimeout(() => { + toast.style.transition = 'opacity .5s'; + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 500); + }, 5000); + } + // ── 全屏特效事件监听(烟花/下雨/雷电/下雪)───────── window.addEventListener('chat:effect', (e) => { const type = e.detail?.type; diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index e577d0f..2196654 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -86,6 +86,8 @@ whisperList: [], showAnnounce: false, announceText: '', + is_friend: false, // 当前用户是否已将对方加为好友 + friendLoading: false, // 好友操作加载状态 gifts: window.__gifts || [], selectedGiftId: window.__defaultGiftId || 0, giftCount: 1, @@ -107,6 +109,51 @@ $alert: (...args) => window.chatDialog.alert(...args), $confirm: (...args) => window.chatDialog.confirm(...args), + /** 切换好友关系(加好友 / 删好友) */ + async toggleFriend() { + if (this.friendLoading) return; + this.friendLoading = true; + const username = this.userInfo.username; + const roomId = window.chatContext.roomId; + const removing = this.is_friend; + + try { + let res; + if (removing) { + // 删除好友 + res = await fetch(`/friend/${encodeURIComponent(username)}/remove`, { + method: 'DELETE', + headers: this._headers(), + body: JSON.stringify({ + room_id: roomId + }), + }); + } else { + // 添加好友 + res = await fetch(`/friend/${encodeURIComponent(username)}/add`, { + method: 'POST', + headers: this._headers(), + body: JSON.stringify({ + room_id: roomId + }), + }); + } + const data = await res.json(); + const ok = data.status === 'success'; + this.$alert( + data.message, + ok ? (removing ? '已删除好友' : '添加成功 🎉') : '操作失败', + ok ? (removing ? '#6b7280' : '#16a34a') : '#cc4444' + ); + if (ok) { + this.is_friend = !this.is_friend; + } + } catch (e) { + this.$alert('网络异常', '错误', '#cc4444'); + } + this.friendLoading = false; + }, + /** 获取用户资料 */ async fetchUser(username) { try { @@ -126,6 +173,19 @@ const data = await res.json(); if (data.status === 'success') { this.userInfo = data.data; + this.showPositionHistory = false; + + // 加载好友状态(仅对非自己的用户查询) + if (data.data.username !== window.chatContext.username) { + fetch(`/friend/${encodeURIComponent(data.data.username)}/status`, { + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }).then(r => r.json()).then(s => { + this.is_friend = s.is_friend ?? false; + }); + } this.showUserModal = true; this.isMuting = false; this.showWhispers = false; @@ -554,13 +614,15 @@