// 好友面板事件绑定与列表渲染,替代 Blade 内联脚本。 let friendPanelEventsBound = false; /** * 获取当前房间 ID。 * * @returns {number|null} */ function roomId() { return window.chatContext?.roomId ?? null; } /** * 获取 CSRF Token。 * * @returns {string} */ function csrf() { return document.querySelector('meta[name="csrf-token"]')?.content ?? ""; } /** * 在面板顶部显示操作结果提示文字。 * * @param {string} message 提示文字 * @param {string} color 文字颜色 * @returns {void} */ function setNotice(message, color = "#888") { const notice = document.getElementById("fp-notice"); if (!notice) { return; } notice.textContent = message; notice.style.color = color; } /** * 打开好友面板并自动拉取最新好友数据。 * * @returns {void} */ export function openFriendPanel() { const panel = document.getElementById("friend-panel"); if (!panel) { return; } panel.style.display = "flex"; loadFriends(); } /** * 关闭好友面板。 * * @returns {void} */ export function closeFriendPanel() { const panel = document.getElementById("friend-panel"); if (panel) { panel.style.display = "none"; } } /** * 从服务端读取好友列表,接口返回 friends 与 pending 两组数据。 * * @returns {void} */ export function loadFriends() { const body = document.getElementById("friend-panel-body"); if (!body) { return; } body.innerHTML = '
加载中…
'; setNotice(""); fetch("/friends", { headers: { Accept: "application/json", "X-CSRF-TOKEN": csrf(), }, }) .then((response) => response.json()) .then((data) => renderFriends(data.friends || [], data.pending || [])) .catch(() => { body.innerHTML = '
加载失败,请重试
'; }); } /** * 渲染好友列表与待回加列表。 * * @param {Array>} friends 我已添加的好友 * @param {Array>} pending 对方已加我但我未回加的用户 * @returns {void} */ function renderFriends(friends, pending) { const body = document.getElementById("friend-panel-body"); if (!body) { return; } body.innerHTML = ""; const friendTitle = document.createElement("div"); friendTitle.className = "fp-section-title"; friendTitle.textContent = `📋 我关注的好友(${friends.length})`; body.appendChild(friendTitle); if (friends.length === 0) { appendEmpty(body, "还没有添加任何好友"); } else { friends.forEach((friend) => body.appendChild(makeFriendRow(friend))); } const pendingTitle = document.createElement("div"); pendingTitle.className = "fp-section-title"; pendingTitle.style.marginTop = "10px"; pendingTitle.textContent = `💌 对方已加我,待我回加(${pending.length})`; body.appendChild(pendingTitle); if (pending.length === 0) { appendEmpty(body, "暂无"); } else { pending.forEach((pendingUser) => body.appendChild(makePendingRow(pendingUser))); } } /** * 向容器追加空状态提示。 * * @param {HTMLElement} container 父容器 * @param {string} text 提示文字 * @returns {void} */ function appendEmpty(container, text) { const empty = document.createElement("div"); empty.className = "fp-empty"; empty.textContent = text; container.appendChild(empty); } /** * 解析好友头像路径。 * * @param {unknown} headface 头像字段 * @returns {string} */ function resolveFriendAvatar(headface) { const avatar = String(headface || "1.gif"); // 后端可能返回 storage 相对路径或头像文件名,这里统一转成可直接使用的图片地址。 return avatar.startsWith("storage/") ? `/${avatar}` : `/images/headface/${avatar}`; } /** * 创建我关注的好友行。 * * @param {Record} friend 好友数据 * @returns {HTMLElement} */ function makeFriendRow(friend) { const row = document.createElement("div"); row.className = "fp-row"; const username = String(friend.username || ""); const avatar = document.createElement("img"); avatar.className = "fp-avatar"; avatar.src = resolveFriendAvatar(friend.headface); avatar.alt = username; const name = document.createElement("span"); name.className = "fp-name"; name.textContent = username; const status = document.createElement("span"); status.className = `fp-status ${friend.is_online ? "fp-status-online" : "fp-status-offline"}`; status.textContent = friend.is_online ? "🟢 在线" : "⚫ 离线"; const badge = document.createElement("span"); badge.className = `fp-badge ${friend.mutual ? "fp-badge-mutual" : "fp-badge-onesided"}`; badge.textContent = friend.mutual ? "💚 互相好友" : "👤 单向关注"; const date = document.createElement("span"); date.className = "fp-date"; date.textContent = String(friend.sub_time || ""); const button = document.createElement("button"); button.className = "fp-action-btn fp-btn-remove"; button.textContent = "删除"; // 好友行是接口返回后动态创建的,按钮监听直接挂在当前行元素上即可。 button.addEventListener("click", () => { void friendAction("remove", username, button); }); row.append(avatar, name, status, badge, date, button); return row; } /** * 创建待回加用户行。 * * @param {Record} pendingUser 待回加用户数据 * @returns {HTMLElement} */ function makePendingRow(pendingUser) { const row = document.createElement("div"); row.className = "fp-row"; const username = String(pendingUser.username || ""); const avatar = document.createElement("img"); avatar.className = "fp-avatar"; avatar.src = resolveFriendAvatar(pendingUser.headface); avatar.alt = username; const name = document.createElement("span"); name.className = "fp-name"; name.textContent = username; const status = document.createElement("span"); status.className = `fp-status ${pendingUser.is_online ? "fp-status-online" : "fp-status-offline"}`; status.textContent = pendingUser.is_online ? "🟢 在线" : "⚫ 离线"; const date = document.createElement("span"); date.className = "fp-date"; date.textContent = pendingUser.added_at ? `他于 ${pendingUser.added_at} 添加了我` : ""; const button = document.createElement("button"); button.className = "fp-action-btn fp-btn-add"; button.textContent = "➕ 回加"; button.addEventListener("click", () => { void friendAction("add", username, button); }); row.append(avatar, name, status, date, button); return row; } /** * 添加或删除好友,与双击用户卡片复用同一组后端接口。 * * @param {"add"|"remove"} action 操作类型 * @param {string} username 目标用户名 * @param {HTMLButtonElement} button 触发按钮 * @returns {Promise} */ async function friendAction(action, username, button) { button.disabled = true; button.style.opacity = "0.5"; setNotice(""); try { // 用户名进入 URL path 前必须编码,避免特殊字符破坏路径或请求目标。 const response = await fetch(`/friend/${encodeURIComponent(username)}/${action}`, { method: action === "remove" ? "DELETE" : "POST", headers: { "X-CSRF-TOKEN": csrf(), "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ // 房间 ID 用于后端生成房间内通知或校验上下文,不参与前端判权。 room_id: roomId(), }), }); const data = await response.json(); if (data.status === "success") { setNotice(data.message, "#16a34a"); // 延迟刷新,等待后端好友状态和通知链路落定。 setTimeout(loadFriends, 700); return; } setNotice(data.message || "操作失败", "#dc2626"); button.disabled = false; button.style.opacity = "1"; } catch (error) { setNotice("网络异常,请重试", "#dc2626"); button.disabled = false; button.style.opacity = "1"; } } /** * 聊天消息和横幅内的快捷好友操作。 * * @param {"add"|"remove"|string} action 操作类型 * @param {string} username 目标用户名 * @param {HTMLElement} element 触发元素 * @returns {Promise} */ export async function quickFriendAction(action, username, element) { if (!["add", "remove"].includes(action) || !username || !(element instanceof HTMLElement)) { return; } if (element.dataset.done) { return; } element.dataset.done = "1"; element.textContent = "处理中…"; element.style.pointerEvents = "none"; try { // 消息内链接来自后端 HTML,用户名进入 path 前仍必须编码。 const response = await fetch(`/friend/${encodeURIComponent(username)}/${action}`, { method: action === "add" ? "POST" : "DELETE", headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": csrf(), Accept: "application/json", }, body: JSON.stringify({ room_id: roomId(), }), }); const data = await response.json(); if (data.status === "success") { element.textContent = action === "add" ? "✅ 已回加" : "✅ 已移除"; element.style.color = "#16a34a"; element.style.textDecoration = "none"; return; } element.textContent = `❌ ${data.message || "操作失败"}`; element.style.color = "#cc4444"; } catch (error) { element.textContent = "❌ 网络错误"; element.style.color = "#cc4444"; delete element.dataset.done; element.style.pointerEvents = ""; } } /** * 通过搜索框按用户名添加好友,具体校验仍交给后端。 * * @returns {Promise} */ export async function friendSearch() { const input = document.getElementById("friend-search-input"); if (!(input instanceof HTMLInputElement)) { return; } const username = input.value.trim(); if (!username) { setNotice("请输入用户名", "#b45309"); return; } const button = document.getElementById("friend-search-btn"); if (!(button instanceof HTMLButtonElement)) { return; } button.disabled = true; setNotice("正在添加…"); try { // 搜索输入的用户名同样先做 path 编码,再交由后端做存在性与权限校验。 const response = await fetch(`/friend/${encodeURIComponent(username)}/add`, { method: "POST", headers: { "X-CSRF-TOKEN": csrf(), "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ // 搜索添加也带上当前房间,保持和用户卡片添加好友入口一致。 room_id: roomId(), }), }); const data = await response.json(); if (data.status === "success") { setNotice(data.message, "#16a34a"); input.value = ""; // 延迟刷新,等待后端好友状态和通知链路落定。 setTimeout(loadFriends, 700); } else { setNotice(data.message || "添加失败", "#dc2626"); } } catch (error) { setNotice("网络异常", "#dc2626"); } finally { button.disabled = false; } } /** * 绑定好友面板关闭、遮罩关闭与搜索入口事件。 * * @returns {void} */ export function bindFriendPanelControls() { if (friendPanelEventsBound || typeof document === "undefined") { return; } friendPanelEventsBound = true; document.addEventListener("click", (event) => { if (!(event.target instanceof Element)) { return; } if (event.target.closest("[data-friend-panel-close]")) { event.preventDefault(); closeFriendPanel(); return; } if (event.target.closest("[data-friend-search]")) { event.preventDefault(); void friendSearch(); return; } const quickAction = event.target.closest("[data-quick-friend-action]"); if (quickAction) { event.preventDefault(); // 后端系统消息只输出 data 属性,具体请求仍统一走模块方法。 void quickFriendAction( quickAction.getAttribute("data-quick-friend-action") || "", quickAction.getAttribute("data-quick-friend-username") || "", quickAction, ); return; } const panel = event.target.closest("#friend-panel"); // 只在点击遮罩本身时关闭,避免点击内容区误关。 if (panel && event.target === panel) { closeFriendPanel(); } }); document.addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.target?.id !== "friend-search-input") { return; } event.preventDefault(); void friendSearch(); }); }