Files
chatroom/resources/js/chat-room/friend-panel.js
T
2026-04-25 10:20:21 +08:00

454 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 好友面板事件绑定与列表渲染,替代 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 = '<div class="fp-empty">加载中…</div>';
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 = '<div class="fp-empty">加载失败,请重试</div>';
});
}
/**
* 渲染好友列表与待回加列表。
*
* @param {Array<Record<string, unknown>>} friends 我已添加的好友
* @param {Array<Record<string, unknown>>} 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<string, unknown>} 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<string, unknown>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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();
});
}