diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 0c6b10f..fca2819 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -4,6 +4,7 @@ export { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; export { bindGlobalDialogControls } from "./chat-room/dialog.js"; export { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; export { bindChatImageUploadControl } from "./chat-room/image-upload.js"; +export { bindFriendPanelControls, closeFriendPanel, friendSearch, loadFriends, openFriendPanel } from "./chat-room/friend-panel.js"; export { closeChatImageLightbox, initChatImageLightboxEvents, openChatImageLightbox } from "./chat-room/lightbox.js"; export { bindMobileDrawerControls } from "./chat-room/mobile-drawer.js"; export { bindWelcomeMenuControls } from "./chat-room/welcome-menu.js"; @@ -36,6 +37,7 @@ import { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; import { bindGlobalDialogControls } from "./chat-room/dialog.js"; import { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js"; import { bindChatImageUploadControl } from "./chat-room/image-upload.js"; +import { bindFriendPanelControls, closeFriendPanel, friendSearch, loadFriends, openFriendPanel } from "./chat-room/friend-panel.js"; import { closeChatImageLightbox, initChatImageLightboxEvents, openChatImageLightbox } from "./chat-room/lightbox.js"; import { bindMobileDrawerControls } from "./chat-room/mobile-drawer.js"; import { bindWelcomeMenuControls } from "./chat-room/welcome-menu.js"; @@ -72,6 +74,11 @@ if (typeof window !== "undefined") { applyFontSize, bindChatFontSizeControl, bindChatImageUploadControl, + bindFriendPanelControls, + closeFriendPanel, + friendSearch, + loadFriends, + openFriendPanel, bindMobileDrawerControls, bindWelcomeMenuControls, CHAT_FONT_SIZE_STORAGE_KEY, @@ -103,10 +110,14 @@ if (typeof window !== "undefined") { window.closeChatImageLightbox = closeChatImageLightbox; window.openChatImageLightbox = openChatImageLightbox; + window.closeFriendPanel = closeFriendPanel; + window.friendSearch = friendSearch; + window.openFriendPanel = openFriendPanel; window.applyFontSize = applyFontSize; bindGlobalDialogControls(); bindChatFontSizeControl(); bindChatImageUploadControl(); + bindFriendPanelControls(); bindChatRightPanelControls(); bindMobileDrawerControls(); bindWelcomeMenuControls(); diff --git a/resources/js/chat-room/friend-panel.js b/resources/js/chat-room/friend-panel.js new file mode 100644 index 0000000..12c985f --- /dev/null +++ b/resources/js/chat-room/friend-panel.js @@ -0,0 +1,382 @@ +// 好友面板事件绑定与列表渲染,替代 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"); + + 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 { + 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({ + 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"; + } +} + +/** + * 通过搜索框按用户名添加好友,具体校验仍交给后端。 + * + * @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 { + 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 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(); + }); +} diff --git a/resources/views/chat/partials/friend-panel.blade.php b/resources/views/chat/partials/friend-panel.blade.php index 4f3650f..a750285 100644 --- a/resources/views/chat/partials/friend-panel.blade.php +++ b/resources/views/chat/partials/friend-panel.blade.php @@ -251,14 +251,13 @@ {{-- 标题栏 --}}
👥 我的好友 - +
{{-- 搜索/添加新好友 --}}
- - + +
{{-- 操作结果提示 --}} @@ -270,304 +269,3 @@ - -{{-- ════ JavaScript ════ --}} -