feat: 好友系统全实现

后端:
- 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 动画
This commit is contained in:
2026-03-01 00:48:51 +08:00
parent 8853d08e5a
commit 700ab9def4
9 changed files with 557 additions and 18 deletions
@@ -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;
}
@@ -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(
`💚 <b>${e.from_username}</b> 将你加为好友了!`,
'#16a34a'
);
})
.listen('.FriendRemoved', (e) => {
showFriendToast(
`💔 <b>${e.from_username}</b> 已将你从好友列表移除。`,
'#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 = `
<div style="font-weight:bold; margin-bottom:4px; color:${color};">💬 好友通知</div>
<div>${html}</div>
`;
// 点击关闭
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;
@@ -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 @@
<div x-data="{ showGiftPanel: false }" x-show="userInfo.username !== window.chatContext.username">
<div class="modal-actions" style="margin-bottom: 0;">
{{-- 写私信 --}}
<a class="btn-mail"
:href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank">
写私信
</a>
{{-- 加好友 / 删好友(替代写私信 --}}
<button x-on:click="toggleFriend()" :disabled="friendLoading"
:style="is_friend
?
'background: #f1f5f9; color: #6b7280; border: 1px solid #d1d5db;' :
'background: linear-gradient(135deg,#16a34a,#22c55e); color:#fff; border:none;'"
style="padding: 7px 14px; border-radius: 5px; font-size: 12px;
cursor: pointer; font-weight: bold; transition: opacity .15s;"
x-text="friendLoading ? '处理中…' : (is_friend ? '✅ 已是好友 (点击删除)' : ' 加好友')"></button>
{{-- 送花按鈕(与写私信并列) --}}
<button class="btn-whisper" x-on:click="showGiftPanel = !showGiftPanel">
🎁 送礼物