2026-03-01 13:47:51 +08:00
|
|
|
|
{{--
|
|
|
|
|
|
文件功能:好友列表浮窗面板
|
|
|
|
|
|
|
|
|
|
|
|
提供全局 JS API:
|
|
|
|
|
|
window.openFriendPanel() 打开好友面板(自动刷新列表)
|
|
|
|
|
|
window.closeFriendPanel() 关闭好友面板
|
|
|
|
|
|
|
|
|
|
|
|
面板分为两个区域:
|
|
|
|
|
|
1. 📋 我关注的好友 —— 我已添加的人,显示互相状态、添加时间、删除按钮
|
|
|
|
|
|
2. 💌 对方已加我 —— 别人加了我但我未回加,显示对方添加时间、回加按钮
|
|
|
|
|
|
|
|
|
|
|
|
搜索栏支持输入用户名后按 Enter 或点击「➕ 添加好友」直接添加。
|
|
|
|
|
|
|
|
|
|
|
|
所有添加/删除操作调用与双击用户卡片完全相同的后端接口:
|
|
|
|
|
|
POST /friend/{username}/add
|
|
|
|
|
|
DELETE /friend/{username}/remove
|
|
|
|
|
|
|
|
|
|
|
|
依赖:
|
|
|
|
|
|
- window.chatContext.roomId 当前房间 ID(由 frame.blade.php 注入)
|
|
|
|
|
|
- meta[name="csrf-token"] CSRF 令牌
|
|
|
|
|
|
|
|
|
|
|
|
@author ChatRoom Laravel
|
|
|
|
|
|
@version 1.0.0
|
|
|
|
|
|
--}}
|
|
|
|
|
|
|
|
|
|
|
|
{{-- ════ 样式 ════ --}}
|
|
|
|
|
|
<style>
|
|
|
|
|
|
#friend-panel {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
background: rgba(0, 0, 0, .5);
|
|
|
|
|
|
z-index: 9999;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-panel-inner {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
width: 480px;
|
|
|
|
|
|
max-width: 96vw;
|
|
|
|
|
|
max-height: 86vh;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-panel-header {
|
|
|
|
|
|
background: linear-gradient(135deg, #336699, #5a8fc0);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-panel-title {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-panel-close {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
opacity: .8;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
transition: opacity .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-panel-close:hover {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-add-bar {
|
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
border-bottom: 1px solid #e8eef5;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
background: #f6faff;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-search-input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
padding: 5px 10px;
|
|
|
|
|
|
border: 1px solid #aac;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-search-input:focus {
|
|
|
|
|
|
border-color: #336699;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-search-btn {
|
|
|
|
|
|
padding: 5px 14px;
|
|
|
|
|
|
background: #336699;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-search-btn:hover {
|
|
|
|
|
|
background: #2a5580;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#friend-panel-body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
background: #f6faff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-section-title {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #336699;
|
|
|
|
|
|
padding: 4px 0 6px;
|
|
|
|
|
|
border-bottom: 1px solid #cde;
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
margin-bottom: 5px;
|
|
|
|
|
|
border: 1px solid #e0eaf5;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-row:hover {
|
|
|
|
|
|
background: #eef5ff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-avatar {
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
border: 1px solid #aac;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-name {
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #224466;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-badge {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
padding: 1px 6px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-badge-mutual {
|
|
|
|
|
|
background: #e8fde8;
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-badge-onesided {
|
|
|
|
|
|
background: #fff3e0;
|
|
|
|
|
|
color: #b45309;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-date {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-action-btn {
|
|
|
|
|
|
padding: 3px 10px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: opacity .15s;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-action-btn:hover {
|
|
|
|
|
|
opacity: .8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-btn-add {
|
|
|
|
|
|
background: #16a34a;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-btn-remove {
|
|
|
|
|
|
background: #dc2626;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#fp-notice {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: #888;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 4px 0 2px;
|
|
|
|
|
|
min-height: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-empty {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: #aaa;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
padding: 16px 0;
|
|
|
|
|
|
}
|
2026-03-03 17:42:47 +08:00
|
|
|
|
|
|
|
|
|
|
/* 在线状态小圆点 */
|
|
|
|
|
|
.fp-status {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 3px;
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
padding: 1px 6px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-status-online {
|
|
|
|
|
|
background: #dcfce7;
|
|
|
|
|
|
color: #15803d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fp-status-offline {
|
|
|
|
|
|
background: #f1f5f9;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
2026-03-01 13:47:51 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
{{-- ════ HTML ════ --}}
|
|
|
|
|
|
<div id="friend-panel">
|
|
|
|
|
|
<div id="friend-panel-inner">
|
|
|
|
|
|
{{-- 标题栏 --}}
|
|
|
|
|
|
<div id="friend-panel-header">
|
|
|
|
|
|
<span id="friend-panel-title">👥 我的好友</span>
|
|
|
|
|
|
<span id="friend-panel-close" onclick="closeFriendPanel()">✕</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{{-- 搜索/添加新好友 --}}
|
|
|
|
|
|
<div id="friend-add-bar">
|
|
|
|
|
|
<input id="friend-search-input" type="text" maxlength="20" placeholder="输入用户名搜索并添加好友…"
|
|
|
|
|
|
onkeydown="if(event.key==='Enter') friendSearch()">
|
|
|
|
|
|
<button id="friend-search-btn" onclick="friendSearch()">➕ 添加好友</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{{-- 操作结果提示 --}}
|
|
|
|
|
|
<div id="fp-notice"></div>
|
|
|
|
|
|
|
|
|
|
|
|
{{-- 列表主体(由 JS 动态填充) --}}
|
|
|
|
|
|
<div id="friend-panel-body">
|
|
|
|
|
|
<div class="fp-empty">加载中…</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{{-- ════ JavaScript ════ --}}
|
|
|
|
|
|
<script>
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 好友面板控制器。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 功能:
|
|
|
|
|
|
* 1. 打开/关闭好友面板
|
|
|
|
|
|
* 2. 获取并渲染「我关注的好友」和「对方已加我」两个列表
|
|
|
|
|
|
* 3. 添加/删除好友(调用与双击用户卡片相同的后端接口)
|
|
|
|
|
|
* 4. 搜索用户名并直接添加为好友
|
|
|
|
|
|
*/
|
|
|
|
|
|
(function() {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取当前房间 ID(frame.blade.php 注入的 chatContext)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {number|null}
|
|
|
|
|
|
*/
|
|
|
|
|
|
const roomId = () => window.chatContext?.roomId ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取 CSRF Token。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
const csrf = () => document.querySelector('meta[name="csrf-token"]').content;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 打开好友面板并自动拉取最新数据。
|
|
|
|
|
|
*/
|
|
|
|
|
|
window.openFriendPanel = function() {
|
|
|
|
|
|
document.getElementById('friend-panel').style.display = 'flex';
|
|
|
|
|
|
loadFriends();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 关闭好友面板。
|
|
|
|
|
|
*/
|
|
|
|
|
|
window.closeFriendPanel = function() {
|
|
|
|
|
|
document.getElementById('friend-panel').style.display = 'none';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 点击遮罩关闭
|
|
|
|
|
|
document.getElementById('friend-panel').addEventListener('click', function(e) {
|
|
|
|
|
|
if (e.target === this) {
|
|
|
|
|
|
closeFriendPanel();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 在面板顶部显示操作结果提示文字。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} msg 提示文字
|
|
|
|
|
|
* @param {string} color 文字颜色(CSS 颜色值)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function setNotice(msg, color) {
|
|
|
|
|
|
const el = document.getElementById('fp-notice');
|
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
|
el.style.color = color || '#888';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从服务端拉取好友数据并渲染列表。
|
|
|
|
|
|
* 调用 GET /friends 返回 { friends, pending }。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function loadFriends() {
|
|
|
|
|
|
document.getElementById('friend-panel-body').innerHTML =
|
|
|
|
|
|
'<div class="fp-empty">加载中…</div>';
|
|
|
|
|
|
setNotice('');
|
|
|
|
|
|
|
|
|
|
|
|
fetch('/friends', {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
|
'X-CSRF-TOKEN': csrf()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.then(data => renderFriends(data.friends || [], data.pending || []))
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
document.getElementById('friend-panel-body').innerHTML =
|
|
|
|
|
|
'<div class="fp-empty">加载失败,请重试</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 渲染两个列表:我关注的好友 + 对方已加我(待回加)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Array} friends 我已添加的好友数组
|
|
|
|
|
|
* @param {Array} pending 对方已加我但我未回加的数组
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderFriends(friends, pending) {
|
|
|
|
|
|
const body = document.getElementById('friend-panel-body');
|
|
|
|
|
|
body.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
// ── 我关注的好友 ──
|
|
|
|
|
|
const h1 = document.createElement('div');
|
|
|
|
|
|
h1.className = 'fp-section-title';
|
|
|
|
|
|
h1.textContent = '📋 我关注的好友(' + friends.length + ')';
|
|
|
|
|
|
body.appendChild(h1);
|
|
|
|
|
|
|
|
|
|
|
|
if (friends.length === 0) {
|
|
|
|
|
|
appendEmpty(body, '还没有添加任何好友');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
friends.forEach(f => body.appendChild(makeFriendRow(f)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 对方已加我(待回加)──
|
|
|
|
|
|
const h2 = document.createElement('div');
|
|
|
|
|
|
h2.className = 'fp-section-title';
|
|
|
|
|
|
h2.style.marginTop = '10px';
|
|
|
|
|
|
h2.textContent = '💌 对方已加我,待我回加(' + pending.length + ')';
|
|
|
|
|
|
body.appendChild(h2);
|
|
|
|
|
|
|
|
|
|
|
|
if (pending.length === 0) {
|
|
|
|
|
|
appendEmpty(body, '暂无');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pending.forEach(p => body.appendChild(makePendingRow(p)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 向容器追加空状态提示。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {HTMLElement} container 父容器
|
|
|
|
|
|
* @param {string} text 提示文字
|
|
|
|
|
|
*/
|
|
|
|
|
|
function appendEmpty(container, text) {
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.className = 'fp-empty';
|
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
|
container.appendChild(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建「我关注的好友」行(头像/用户名/互相徽章/添加时间/删除按钮)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {object} f 好友数据 { username, headface, mutual, sub_time }
|
|
|
|
|
|
* @returns {HTMLElement}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function makeFriendRow(f) {
|
|
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
|
row.className = 'fp-row';
|
|
|
|
|
|
|
|
|
|
|
|
const avatar = document.createElement('img');
|
|
|
|
|
|
avatar.className = 'fp-avatar';
|
2026-03-12 15:26:54 +08:00
|
|
|
|
let hf = f.headface || '1.gif';
|
|
|
|
|
|
avatar.src = hf.startsWith('storage/') ? '/' + hf : '/images/headface/' + hf;
|
2026-03-01 13:47:51 +08:00
|
|
|
|
avatar.alt = f.username;
|
|
|
|
|
|
|
|
|
|
|
|
const name = document.createElement('span');
|
|
|
|
|
|
name.className = 'fp-name';
|
|
|
|
|
|
name.textContent = f.username;
|
|
|
|
|
|
|
2026-03-03 17:42:47 +08:00
|
|
|
|
// 在线状态标记(映名后)
|
|
|
|
|
|
const status = document.createElement('span');
|
|
|
|
|
|
status.className = 'fp-status ' + (f.is_online ? 'fp-status-online' : 'fp-status-offline');
|
|
|
|
|
|
status.textContent = f.is_online ? '🟢 在线' : '⚫ 离线';
|
|
|
|
|
|
|
2026-03-01 13:47:51 +08:00
|
|
|
|
const badge = document.createElement('span');
|
|
|
|
|
|
badge.className = 'fp-badge ' + (f.mutual ? 'fp-badge-mutual' : 'fp-badge-onesided');
|
|
|
|
|
|
badge.textContent = f.mutual ? '💚 互相好友' : '👤 单向关注';
|
|
|
|
|
|
|
|
|
|
|
|
const date = document.createElement('span');
|
|
|
|
|
|
date.className = 'fp-date';
|
|
|
|
|
|
date.textContent = f.sub_time || '';
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.createElement('button');
|
|
|
|
|
|
btn.className = 'fp-action-btn fp-btn-remove';
|
|
|
|
|
|
btn.textContent = '删除';
|
|
|
|
|
|
btn.onclick = () => friendAction('remove', f.username, btn);
|
|
|
|
|
|
|
2026-03-03 17:42:47 +08:00
|
|
|
|
row.append(avatar, name, status, badge, date, btn);
|
2026-03-01 13:47:51 +08:00
|
|
|
|
return row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建「待回加」行(头像/用户名/对方添加时间/回加按钮)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {object} p 待回加用户数据 { username, headface, added_at }
|
|
|
|
|
|
* @returns {HTMLElement}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function makePendingRow(p) {
|
|
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
|
row.className = 'fp-row';
|
|
|
|
|
|
|
|
|
|
|
|
const avatar = document.createElement('img');
|
|
|
|
|
|
avatar.className = 'fp-avatar';
|
2026-03-12 15:26:54 +08:00
|
|
|
|
let hf = p.headface || '1.gif';
|
|
|
|
|
|
avatar.src = hf.startsWith('storage/') ? '/' + hf : '/images/headface/' + hf;
|
2026-03-01 13:47:51 +08:00
|
|
|
|
avatar.alt = p.username;
|
|
|
|
|
|
|
|
|
|
|
|
const name = document.createElement('span');
|
|
|
|
|
|
name.className = 'fp-name';
|
|
|
|
|
|
name.textContent = p.username;
|
|
|
|
|
|
|
2026-03-03 17:42:47 +08:00
|
|
|
|
// 在线状态标记
|
|
|
|
|
|
const status = document.createElement('span');
|
|
|
|
|
|
status.className = 'fp-status ' + (p.is_online ? 'fp-status-online' : 'fp-status-offline');
|
|
|
|
|
|
status.textContent = p.is_online ? '🟢 在线' : '⚫ 离线';
|
|
|
|
|
|
|
2026-03-01 13:47:51 +08:00
|
|
|
|
const date = document.createElement('span');
|
|
|
|
|
|
date.className = 'fp-date';
|
|
|
|
|
|
date.textContent = p.added_at ? '他于 ' + p.added_at + ' 添加了我' : '';
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.createElement('button');
|
|
|
|
|
|
btn.className = 'fp-action-btn fp-btn-add';
|
|
|
|
|
|
btn.textContent = '➕ 回加';
|
|
|
|
|
|
btn.onclick = () => friendAction('add', p.username, btn);
|
|
|
|
|
|
|
2026-03-03 17:42:47 +08:00
|
|
|
|
row.append(avatar, name, status, date, btn);
|
2026-03-01 13:47:51 +08:00
|
|
|
|
return row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 执行添加或删除好友操作。
|
|
|
|
|
|
* 调用与双击用户卡片完全相同的后端接口。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {'add'|'remove'} action 操作类型
|
|
|
|
|
|
* @param {string} username 目标用户名
|
|
|
|
|
|
* @param {HTMLElement} btn 触发按钮(操作期间禁用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function friendAction(action, username, btn) {
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.style.opacity = '0.5';
|
|
|
|
|
|
setNotice('');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = 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 res.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.status === 'success') {
|
|
|
|
|
|
setNotice(data.message, '#16a34a');
|
|
|
|
|
|
setTimeout(loadFriends, 700); // 延迟刷新列表
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setNotice(data.message || '操作失败', '#dc2626');
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.style.opacity = '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setNotice('网络异常,请重试', '#dc2626');
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.style.opacity = '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通过搜索框添加新好友(Enter 或点击按钮触发)。
|
|
|
|
|
|
* 用户名经后端验证(不能加自己、不能重复添加等)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
window.friendSearch = async function() {
|
|
|
|
|
|
const input = document.getElementById('friend-search-input');
|
|
|
|
|
|
const username = input.value.trim();
|
|
|
|
|
|
|
|
|
|
|
|
if (!username) {
|
|
|
|
|
|
setNotice('请输入用户名', '#b45309');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('friend-search-btn');
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
setNotice('正在添加…');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = 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 res.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.status === 'success') {
|
|
|
|
|
|
setNotice(data.message, '#16a34a');
|
|
|
|
|
|
input.value = '';
|
|
|
|
|
|
setTimeout(loadFriends, 700);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setNotice(data.message || '添加失败', '#dc2626');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setNotice('网络异常', '#dc2626');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|