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 ════ --}}
-