功能:好友列表面板
后端(FriendController::index):
- 返回 sub_time 添加时间
- 新增 pending 列表(对方加了我但我未回加)
包含用户信息 + added_at(对方添加我的时间)
前端(toolbar.blade.php):
- 工具栏顶部加「好友」按钮(openFriendPanel)
- 好友弹窗面板(#friend-panel):
① 搜索栏:输入用户名 Enter/按钮添加好友
② 「我关注的好友」列表:头像/用户名/互相徽章/
添加时间/删除按钮
③ 「对方已加我,待我回加」列表:头像/用户名/
对方添加时间/➕回加按钮
④ 面板顶部提示区(成功/失败消息)
- 所有添加/删除调用与双击用户卡片完全相同的接口
(/friend/{username}/add、/friend/{username}/remove)
This commit is contained in:
@@ -168,31 +168,68 @@ class FriendController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的好友列表(我添加的 + 对方也添加我的 = 双向好友标记)。
|
||||
* 获取当前用户的完整好友数据,供好友面板使用。
|
||||
*
|
||||
* 返回两个列表:
|
||||
* - friends:我已添加的好友(含互相状态、添加时间)
|
||||
* - pending:对方已加我但我还未加对方的(含对方添加我的时间)
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
// 我添加的所有人
|
||||
$myAdded = FriendRequest::where('who', $me->username)->pluck('towho');
|
||||
// ── 我添加的好友及添加时间 ──
|
||||
$myRows = FriendRequest::where('who', $me->username)
|
||||
->get(['towho', 'sub_time'])
|
||||
->keyBy('towho');
|
||||
|
||||
// 也把我加了的
|
||||
$addedMe = FriendRequest::where('towho', $me->username)->pluck('who');
|
||||
// ── 把我加了的人(用于互相判断 + pending 列表)──
|
||||
$addedMeRows = FriendRequest::where('towho', $me->username)
|
||||
->get(['who', 'sub_time'])
|
||||
->keyBy('who');
|
||||
|
||||
$friends = User::whereIn('username', $myAdded)
|
||||
$myAddedNames = $myRows->keys();
|
||||
$addedMeNames = $addedMeRows->keys();
|
||||
|
||||
// 我添加的好友详情
|
||||
$friends = User::whereIn('username', $myAddedNames)
|
||||
->get(['username', 'usersf', 'user_level', 'sex'])
|
||||
->map(function ($u) use ($addedMe) {
|
||||
->map(function ($u) use ($myRows, $addedMeNames) {
|
||||
$row = $myRows->get($u->username);
|
||||
|
||||
return [
|
||||
'username' => $u->username,
|
||||
'headface' => $u->headface,
|
||||
'user_level' => $u->user_level,
|
||||
'sex' => $u->sex,
|
||||
'mutual' => $addedMe->contains($u->username), // 是否互相添加
|
||||
'mutual' => $addedMeNames->contains($u->username), // 是否互相添加
|
||||
'sub_time' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
|
||||
];
|
||||
});
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json(['status' => 'success', 'friends' => $friends]);
|
||||
// 对方加了我但我还未加的(pending)
|
||||
$pendingNames = $addedMeNames->diff($myAddedNames);
|
||||
$pending = User::whereIn('username', $pendingNames)
|
||||
->get(['username', 'usersf', 'user_level', 'sex'])
|
||||
->map(function ($u) use ($addedMeRows) {
|
||||
$row = $addedMeRows->get($u->username);
|
||||
|
||||
return [
|
||||
'username' => $u->username,
|
||||
'headface' => $u->headface,
|
||||
'user_level' => $u->user_level,
|
||||
'sex' => $u->sex,
|
||||
'added_at' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'friends' => $friends,
|
||||
'pending' => $pending,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
{{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
|
||||
<div class="chat-toolbar" id="toolbar-strip">
|
||||
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
|
||||
<div class="tool-btn" onclick="openShopModal()" title="购买道具">商店</div>
|
||||
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
|
||||
<div class="tool-btn" onclick="alert('🚧 娱乐功能开发中,敬请期待!')" title="娱乐(待开发)">娱乐</div>
|
||||
@@ -165,6 +166,448 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ═══════════ 工具条相关 JS 函数 ═══════════ --}}
|
||||
<script>
|
||||
{{-- ═══════════ 好友面板弹窗 ═══════════ --}}
|
||||
<
|
||||
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: 8 px;
|
||||
width: 480 px;
|
||||
max - width: 96 vw;
|
||||
max - height: 86 vh;
|
||||
display: flex;
|
||||
flex - direction: column;
|
||||
box - shadow: 0 8 px 32 px rgba(0, 0, 0, .3);
|
||||
overflow: hidden;
|
||||
}
|
||||
#friend - panel - header {
|
||||
background: linear - gradient(135 deg, #336699,# 5 a8fc0);
|
||||
color: #fff;
|
||||
padding: 10 px 16 px;
|
||||
display: flex;
|
||||
align - items: center;
|
||||
gap: 10 px;
|
||||
flex - shrink: 0;
|
||||
}
|
||||
#friend - panel - title {
|
||||
font - size: 14 px;
|
||||
font - weight: bold;
|
||||
flex: 1;
|
||||
}
|
||||
#friend - panel - close {
|
||||
cursor: pointer;font - size: 18 px;opacity: .8;line - height: 1;
|
||||
transition: opacity .15 s;
|
||||
}
|
||||
#friend - panel - close: hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 搜索添加栏 */
|
||||
#friend - add - bar {
|
||||
padding: 8 px 12 px;
|
||||
border - bottom: 1 px solid #e8eef5;
|
||||
display: flex;
|
||||
gap: 6 px;
|
||||
background: #f6faff;
|
||||
flex - shrink: 0;
|
||||
}
|
||||
#friend - search - input {
|
||||
flex: 1;
|
||||
padding: 5 px 10 px;
|
||||
border: 1 px solid #aac;
|
||||
border - radius: 4 px;
|
||||
font - size: 12 px;
|
||||
outline: none;
|
||||
}
|
||||
#friend - search - input: focus {
|
||||
border - color: #336699; }
|
||||
# friend - search - btn {
|
||||
padding: 5 px 14 px;
|
||||
background: #336699; color: # fff;
|
||||
border: none;border - radius: 4 px;
|
||||
font - size: 12 px;font - weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
#friend - search - btn: hover {
|
||||
background: #2a5580; }
|
||||
|
||||
/* 列表主体 */
|
||||
# friend - panel - body {
|
||||
flex: 1;
|
||||
overflow - y: auto;
|
||||
padding: 10 px 12 px;
|
||||
background: #f6faff;
|
||||
}
|
||||
.fp - section - title {
|
||||
font - size: 11 px;
|
||||
font - weight: bold;
|
||||
color: #336699;
|
||||
padding: 4px 0 6px;
|
||||
border-bottom: 1px solid # cde;
|
||||
margin - bottom: 6 px;
|
||||
}
|
||||
.fp - row {
|
||||
display: flex;
|
||||
align - items: center;
|
||||
gap: 8 px;
|
||||
padding: 6 px 8 px;
|
||||
border - radius: 5 px;
|
||||
background: #fff;
|
||||
margin - bottom: 5 px;
|
||||
border: 1 px solid #e0eaf5;
|
||||
font - size: 12 px;
|
||||
}
|
||||
.fp - row: hover {
|
||||
background: #eef5ff;
|
||||
}
|
||||
.fp - avatar {
|
||||
width: 28 px;height: 28 px;
|
||||
border - radius: 3 px;border: 1 px 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: 10 px;
|
||||
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:# 16 a34a;
|
||||
color: #fff;
|
||||
}
|
||||
.fp - btn - remove {
|
||||
background: #dc2626;color: #fff;
|
||||
}
|
||||
|
||||
/* 提示 */
|
||||
#fp - notice {
|
||||
font - size: 11 px;
|
||||
color: #888;
|
||||
text-align:center; padding:4px 0 2px;
|
||||
min-height:18px;
|
||||
}
|
||||
.fp-empty { text-align:center; color:# aaa;
|
||||
font - size: 12 px;
|
||||
padding: 16 px 0;
|
||||
} <
|
||||
/style>
|
||||
|
||||
<
|
||||
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>
|
||||
|
||||
<
|
||||
div id = "friend-panel-body" >
|
||||
<
|
||||
div class = "fp-empty"
|
||||
id = "fp-loading" > 加载中… < /div> <
|
||||
/div> <
|
||||
/div> <
|
||||
/div>
|
||||
|
||||
<
|
||||
script >
|
||||
/**
|
||||
* 好友面板控制器。
|
||||
* 复用 /friend/{username}/add 和 /friend/{username}/remove 接口,
|
||||
* 与双击用户卡片执行的是完全相同的后端逻辑。
|
||||
*/
|
||||
(function() {
|
||||
const roomId = () => window.chatContext?.roomId ?? null;
|
||||
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();
|
||||
});
|
||||
|
||||
/** 设置面板顶部提示文字 */
|
||||
function setNotice(msg, color = '#888') {
|
||||
const el = document.getElementById('fp-notice');
|
||||
el.textContent = msg;
|
||||
el.style.color = color;
|
||||
}
|
||||
|
||||
/** 拉取好友数据并渲染 */
|
||||
function loadFriends() {
|
||||
const body = document.getElementById('friend-panel-body');
|
||||
body.innerHTML = '<div class="fp-empty" id="fp-loading">加载中…</div>';
|
||||
setNotice('');
|
||||
|
||||
fetch('/friends', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrf()
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => renderFriends(data.friends || [], data.pending || []))
|
||||
.catch(() => {
|
||||
body.innerHTML = '<div class="fp-empty">加载失败,请重试</div>';
|
||||
});
|
||||
}
|
||||
|
||||
/** 渲染两个列表 */
|
||||
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) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'fp-empty';
|
||||
empty.textContent = '还没有添加任何好友';
|
||||
body.appendChild(empty);
|
||||
} 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) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'fp-empty';
|
||||
empty.textContent = '暂无';
|
||||
body.appendChild(empty);
|
||||
} else {
|
||||
pending.forEach(p => body.appendChild(makePendingRow(p)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建「我的好友」行(含互相徽章、日期、删除按钮)。
|
||||
*
|
||||
* @param {object} f 好友数据
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function makeFriendRow(f) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'fp-row';
|
||||
row.id = 'fp-row-friend-' + f.username;
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'fp-avatar';
|
||||
avatar.src = '/images/headface/' + (f.headface || '1.gif');
|
||||
avatar.alt = f.username;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'fp-name';
|
||||
name.textContent = f.username;
|
||||
|
||||
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);
|
||||
|
||||
row.append(avatar, name, badge, date, btn);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建「待回加」行(添加时间、添加好友按钮)。
|
||||
*
|
||||
* @param {object} p 待回加用户数据
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function makePendingRow(p) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'fp-row';
|
||||
row.id = 'fp-row-pending-' + p.username;
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'fp-avatar';
|
||||
avatar.src = '/images/headface/' + (p.headface || '1.gif');
|
||||
avatar.alt = p.username;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'fp-name';
|
||||
name.textContent = p.username;
|
||||
|
||||
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, true);
|
||||
|
||||
row.append(avatar, name, date, btn);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行添加/删除好友操作(与双击用户卡片调用完全相同的后端接口)。
|
||||
*
|
||||
* @param {'add'|'remove'} action 操作类型
|
||||
* @param {string} username 目标用户名
|
||||
* @param {HTMLElement} btn 触发的按钮(用于禁用防重复)
|
||||
* @param {boolean} isPending 是否来自 pending 区域
|
||||
*/
|
||||
async function friendAction(action, username, btn, isPending = false) {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.5';
|
||||
setNotice('');
|
||||
|
||||
try {
|
||||
const isRemove = action === 'remove';
|
||||
const res = await fetch(`/friend/${encodeURIComponent(username)}/${action}`, {
|
||||
method: isRemove ? '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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户并添加为好友。
|
||||
* 输入框中的用户名经后端验证后执行添加。
|
||||
*/
|
||||
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>
|
||||
|
||||
{{-- ═══════════ 工具条相关 JS 函数 ═══════════ --}}
|
||||
<script>
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user