Files
chatroom/resources/views/chat/partials/user-actions.blade.php
lkddi 529a59551c 修复(chat): 新增真实 IP 获取中间件及重构用户 IP 轨迹追踪逻辑
- 新增 CloudflareProxies 前置中间件,强制解析 CDN 透传的 CF-Connecting-IP 与 X-Real-IP 并在底层接管,修复 Nginx 代理造成的全局 IP 同化 (127.0.0.1) 问题
- 修改 User 模型,新增 migration 以补全真正的 previous_ip 储存通道
- 修改 AuthController 登录逻辑,在覆写 last_ip 前实现向 previous_ip 的自动历史快照备份
- 修改 UserController API 返回逻辑,实现 first_ip、last_ip(上次)以及 login_ip(本次)的三轨分离
- 更新 user-actions.blade.php 管理员视野面板,同步增加并校验“首次IP”、“上次IP”、“本次IP”三级字段映射的准确性
2026-03-09 11:53:58 +08:00

1377 lines
71 KiB
PHP
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.
{{--
文件功能:用户交互全局函数 + 名片弹窗组件
包含:
1. switchTarget() 单击用户名切换聊天目标
2. openUserCard() 双击用户名打开名片弹窗
3. 用户名片弹窗 Alpine.js 组件(资料查看 + 送花 + 管理操作)
scripts.blade.php frame.blade.php 中抽取,保持代码职责清晰。
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ═══════════ 全局交互函数 ═══════════ --}}
<script>
/**
* 全局函数:单击用户名 → 切换聊天目标
*
* 将「对...说」下拉选中为该用户,方便直接发悄悄话。
* 若用户已离线不在下拉列表中,临时添加一个 option。
*/
function switchTarget(username) {
const toUserSelect = document.getElementById('to_user');
if (!toUserSelect) return;
const options = toUserSelect.options;
let found = false;
for (let i = 0; i < options.length; i++) {
if (options[i].value === username) {
toUserSelect.value = username;
found = true;
break;
}
}
// 如果不在列表中(可能已离线),临时添加
if (!found && username !== '大家') {
const opt = document.createElement('option');
opt.value = username;
opt.textContent = username;
toUserSelect.appendChild(opt);
toUserSelect.value = username;
}
// 切换目标后自动聚焦输入框,方便直接输入
document.getElementById('content').focus();
}
/**
* 全局函数:双击用户名 → 打开名片弹窗
*
* 聊天消息区和右侧用户列表统一调用此函数。
* 通过 Alpine.js 的 fetchUser 方法加载用户资料并显示弹窗。
*/
function openUserCard(username) {
// 剥除可能从消息内容带入的装饰括号(如 【username】 → username
username = String(username).replace(/^[\u3010\[【\s]+|[\u3011\]】\s]+$/g, '').trim();
if (!username) return;
const el = document.getElementById('user-modal-container');
if (el) {
const data = Alpine.$data(el);
if (data) data.fetchUser(username);
}
}
</script>
{{-- ═══════════ 用户名片弹窗 (Alpine.js) ═══════════ --}}
@php $gifts = \App\Models\Gift::activeList(); @endphp
<script>
/**
* 礼物数据注入(避免 JSON 破坏 x-data 属性解析)
*/
window.__gifts = {!! Js::from($gifts) !!};
window.__defaultGiftId = {{ $gifts->first()?->id ?? 0 }};
/**
* 用户名片弹窗 Alpine.js 组件定义
* 提取到 script 标签避免 HTML 属性中的引号冲突
*/
function userCardComponent() {
return {
showUserModal: false,
userInfo: {
position_history: []
},
isMuting: false,
muteDuration: 5,
showWhispers: false,
whisperList: [],
showAnnounce: false,
announceText: '',
is_friend: false, // 当前用户是否已将对方加为好友
friendLoading: false, // 好友操作加载状态
gifts: window.__gifts || [],
selectedGiftId: window.__defaultGiftId || 0,
giftCount: 1,
sendingGift: false,
// 职务奖励金币
rewardAmount: 0,
sendingReward: false,
showRewardPanel: false,
// 任命相关
showAppointPanel: false,
appointPositions: [],
selectedPositionId: null,
appointRemark: '',
appointLoading: false,
// 折叠状态
showAdminView: false, // 管理员视野
showPositionHistory: false, // 职务履历
showAdminPanel: false, // 管理操作(管理操作+职务操作合并)
// 婚姻状态
targetMarriage: null, // 对方婚姻状态 { status, partner_name, marriage_id }
marriageLoading: false,
mySex: window.chatContext?.userSex ?? '', // 当前用户性别(用于求婚异性判断)
// 自定义弹窗:直接代理到全局 window.chatDialog
$alert: (...args) => window.chatDialog.alert(...args),
$confirm: (...args) => window.chatDialog.confirm(...args),
$prompt: (...args) => window.chatDialog.prompt(...args),
/** 切换好友关系(加好友 / 删好友) */
async toggleFriend() {
if (this.friendLoading) return;
this.friendLoading = true;
const username = this.userInfo.username;
const roomId = window.chatContext.roomId;
const removing = this.is_friend;
try {
let res;
if (removing) {
// 删除好友
res = await fetch(`/friend/${encodeURIComponent(username)}/remove`, {
method: 'DELETE',
headers: this._headers(),
body: JSON.stringify({
room_id: roomId
}),
});
} else {
// 添加好友
res = await fetch(`/friend/${encodeURIComponent(username)}/add`, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
room_id: roomId
}),
});
}
const data = await res.json();
const ok = data.status === 'success';
this.$alert(
data.message,
ok ? (removing ? '已删除好友' : '添加成功 🎉') : '操作失败',
ok ? (removing ? '#6b7280' : '#16a34a') : '#cc4444'
);
if (ok) {
this.is_friend = !this.is_friend;
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
this.friendLoading = false;
},
async handleConfirmDivorce(marriageId) {
// 等待后端接口实现,当前先略
return;
},
/** 发起协议离婚(先拉惩罚配置,再弹专属全屏确认弹窗) */
async doDivorce(marriageId) {
if (!marriageId) return;
this.showUserModal = false;
// 从后台实时拉取最新惩罚配置
let divorceConfig = {
mutual_charm_penalty: 0,
forced_charm_penalty: 0,
mutual_cooldown_days: 0,
forced_cooldown_days: 0
};
try {
const cfgRes = await fetch(window.chatContext.marriage.divorceConfigUrl, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (cfgRes.ok) divorceConfig = await cfgRes.json();
} catch (e) {
/* 网络异常则使用默认值 */
}
// 打开专属离婚确认弹窗
const modal = document.getElementById('divorce-confirm-modal');
if (modal && window.Alpine) {
Alpine.$data(modal).open(marriageId, divorceConfig);
}
},
/** 获取用户资料 */
async fetchUser(username) {
try {
const res = await fetch('/user/' + encodeURIComponent(username), {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error('Failed to fetch user:', errorData.message || res.statusText);
return;
}
const data = await res.json();
if (data.status === 'success') {
this.userInfo = data.data;
this.showPositionHistory = false;
// 加载好友状态(仅对非自己的用户查询)
if (data.data.username !== window.chatContext.username) {
fetch(`/friend/${encodeURIComponent(data.data.username)}/status`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(r => r.json()).then(s => {
this.is_friend = s.is_friend ?? false;
});
// 加载对方婚姻状态
this.targetMarriage = null;
this.marriageLoading = true;
fetch(`/marriage/target?username=${encodeURIComponent(data.data.username)}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(r => r.json()).then(m => {
this.targetMarriage = m.marriage ?? null;
}).catch(() => {}).finally(() => {
this.marriageLoading = false;
});
}
this.showUserModal = true;
this.isMuting = false;
this.showWhispers = false;
this.whisperList = [];
this.showAppointPanel = false;
this.selectedPositionId = null;
this.appointRemark = '';
// 有职务的操作人预加载可用职务列表
if (window.chatContext?.hasPosition) {
this._loadPositions();
}
}
} catch (e) {
console.error('Error fetching user:', e);
}
},
/** 加载可任命职务列表 */
async _loadPositions() {
if (!window.chatContext?.appointPositionsUrl) return;
try {
const res = await fetch(window.chatContext.appointPositionsUrl, {
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.status === 'success') {
this.appointPositions = data.positions;
if (this.appointPositions.length > 0) {
this.selectedPositionId = this.appointPositions[0].id;
}
}
} catch (e) {
/* 静默失败 */
}
},
/** 快速任命 */
async doAppoint() {
if (this.appointLoading || !this.selectedPositionId) return;
this.appointLoading = true;
try {
const res = await fetch(window.chatContext.appointUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
position_id: this.selectedPositionId,
remark: this.appointRemark.trim() || null,
room_id: window.chatContext.roomId ?? null,
})
});
const data = await res.json();
const ok = data.status === 'success';
this.$alert(
data.message,
ok ? '任命成功 ✨' : '操作失败',
ok ? '#16a34a' : '#cc4444'
);
if (ok) {
this.showUserModal = false;
}
} catch (e) {
this.$alert('网络异常,请稍后重试', '错误', '#cc4444');
}
this.appointLoading = false;
},
/** 快速撤销 */
async doRevoke() {
const ok = await this.$confirm(
'确定要撤销 「' + this.userInfo.username + '」 的职务吗?撤销后将不再拥有相关权限。',
'撤销职务'
);
if (!ok) return;
this.appointLoading = true;
try {
const res = await fetch(window.chatContext.revokeUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
remark: '聊天室快速撤销',
room_id: window.chatContext.roomId ?? null,
})
});
const data = await res.json();
const revOk = data.status === 'success';
this.$alert(
data.message,
revOk ? '撤销成功' : '操作失败',
revOk ? '#6b7280' : '#cc4444'
);
if (revOk) {
this.showUserModal = false;
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
this.appointLoading = false;
},
/** 踢出用户 */
async kickUser() {
const reason = await this.$prompt('踢出原因(可留空):', '违反聊天室规则', '踢出用户', '#cc4444');
if (reason === null) return;
try {
const res = await fetch('/command/kick', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '违反聊天室规则'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 禁言用户 */
async muteUser() {
try {
const res = await fetch('/command/mute', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
duration: this.muteDuration
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 警告用户 */
async warnUser() {
const reason = await this.$prompt('警告原因:', '请注意言行', '警告用户', '#f59e0b');
if (reason === null) return;
try {
const res = await fetch('/command/warn', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '请注意言行'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 冻结用户 */
async freezeUser() {
const confirmed = await this.$confirm(
'确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录!',
'冻结账号',
'#cc4444'
);
if (!confirmed) return;
const reason = await this.$prompt('冻结原因:', '严重违规', '填写原因', '#cc4444');
if (reason === null) return;
try {
const res = await fetch('/command/freeze', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '严重违规'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 查看私信记录 */
async loadWhispers() {
try {
const res = await fetch('/command/whispers/' + encodeURIComponent(this.userInfo.username), {
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.status === 'success') {
this.whisperList = data.messages;
this.showWhispers = true;
} else {
this.$alert(data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 发送全服公告 */
async sendAnnounce() {
if (!this.announceText.trim()) return;
try {
const res = await fetch('/command/announce', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
content: this.announceText,
room_id: window.chatContext.roomId,
})
});
const data = await res.json();
if (data.status === 'success') {
this.announceText = '';
this.showAnnounce = false;
} else {
this.$alert(data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 送礼物 */
async sendGift() {
if (this.sendingGift || !this.selectedGiftId) return;
this.sendingGift = true;
try {
const res = await fetch('/gift/flower', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
to_user: this.userInfo.username,
room_id: window.chatContext.roomId,
gift_id: this.selectedGiftId,
count: this.giftCount
})
});
const data = await res.json();
this.$alert(data.message, data.status === 'success' ? '送礼成功 🎁' : '操作失败',
data.status === 'success' ? '#e11d48' : '#cc4444');
if (data.status === 'success') {
this.showUserModal = false;
this.giftCount = 1;
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
this.sendingGift = false;
},
/** 职务奖励:向用户发放金币(凭空产生,记入履职记录) */
async sendReward() {
if (this.sendingReward) return;
const maxOnce = window.chatContext?.myMaxReward ?? 0;
const amount = parseInt(this.rewardAmount, 10);
if (!amount || amount <= 0) {
this.$alert('请输入有效的奖励金额', '提示', '#f59e0b');
return;
}
// 0 = 禁止(前端按钮不显示,此处二次保护)
if (maxOnce === 0) {
this.$alert('你的职务没有奖励发放权限', '无权限', '#cc4444');
return;
}
// -1 = 不限,跳过上限校验;正整数 = 有具体上限
if (maxOnce > 0 && amount > maxOnce) {
this.$alert(`单次奖励上限为 ${maxOnce} 金币`, '超出上限', '#cc4444');
return;
}
this.sendingReward = true;
try {
const res = await fetch(window.chatContext.rewardUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
amount,
})
});
const data = await res.json();
const ok = data.status === 'success';
this.$alert(data.message, ok ? '奖励发放成功 🎉' : '操作失败',
ok ? '#d97706' : '#cc4444');
if (ok) {
this.showRewardPanel = false;
this.rewardAmount = 0;
}
} catch (e) {
this.$alert('网络异常,请稍后重试', '错误', '#cc4444');
}
this.sendingReward = false;
},
/** 通用请求头 */
_headers() {
return {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
};
}
};
}
</script>
<div id="user-modal-container" x-data="userCardComponent()">
<div x-show="showUserModal" style="display: none;" class="modal-overlay" x-on:click.self="showUserModal = false">
<div class="modal-card" x-transition>
{{-- 弹窗头部 --}}
<div class="modal-header">
<h3 x-text="'用户名片 · ' + userInfo.username"></h3>
<button class="modal-close" x-on:click="showUserModal = false">&times;</button>
</div>
{{-- 弹窗内容 --}}
<div class="modal-body">
<div class="profile-row">
<img class="profile-avatar" x-show="userInfo.headface"
:src="'/images/headface/' + (userInfo.headface || '1.gif').toLowerCase()"
x-on:error="$el.style.display='none'">
<div class="profile-info">
<h4>
<span x-text="userInfo.username"></span>
<span class="level-badge" x-text="'LV.' + userInfo.user_level"></span>
<span class="sex-badge"
x-text="userInfo.sex === '男' ? '♂' : (userInfo.sex === '女' ? '♀' : '')"
:style="userInfo.sex === '男' ? 'color: blue' : (userInfo.sex === '女' ?
'color: deeppink' : '')"></span>
</h4>
{{-- 在职职务标签(有职务时才显示) --}}
<div x-show="userInfo.position_name"
style="display: inline-flex; align-items: center; gap: 3px; margin-top: 3px; padding: 2px 8px; background: #f3e8ff; border: 1px solid #d8b4fe; border-radius: 20px; font-size: 11px; color: #7c3aed; font-weight: bold;">
<span x-text="userInfo.position_icon" style="font-size: 13px;"></span>
<span
x-text="(userInfo.department_name ? userInfo.department_name + ' · ' : '') + userInfo.position_name"></span>
</div>
<div style="font-size: 11px; color: #999; margin-top: 4px;">
加入: <span x-text="userInfo.created_at"></span>
</div>
</div>
</div>
{{-- 详细信息区:外层 x-show 控制显隐,内层单独写 flex 避免被 Alpine 覆盖 --}}
<div x-show="userInfo.exp_num !== undefined" style="margin-top: 12px;">
<div style="display: flex; flex-direction: row; gap: 8px;">
<!-- 经验 -->
<div
style="flex: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #64748b; font-size: 11px; margin-bottom: 2px;">经验</span>
<span x-text="userInfo.exp_num"
style="font-weight: 700; color: #4f46e5; font-size: 14px;"></span>
</div>
<!-- 金币 -->
<div
style="flex: 1; background: #fdfae8; border: 1px solid #fef08a; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #b45309; font-size: 11px; margin-bottom: 2px;">金币</span>
<span x-text="userInfo.jjb"
style="font-weight: 700; color: #d97706; font-size: 14px;"></span>
</div>
<!-- 魅力 -->
<div
style="flex: 1; background: #fdf2f8; border: 1px solid #fbcfe8; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #be185d; font-size: 11px; margin-bottom: 2px;">魅力</span>
<span x-text="userInfo.meili"
style="font-weight: 700; color: #db2777; font-size: 14px;"></span>
</div>
</div>
</div>
{{-- 管理员可见区域 (IP 归属地) --}}
<div x-show="userInfo.last_ip !== undefined"
style="margin-top: 8px; border-radius: 8px; overflow: hidden;">
{{-- 可点击标题 --}}
<div x-on:click="showAdminView = !showAdminView"
style="display: flex; align-items: center; justify-content: space-between; padding: 6px 10px;
background: #fee2e2; border: 1px dashed #fca5a5; border-radius: 8px;
cursor: pointer; font-size: 11px; color: #991b1b; font-weight: bold; user-select: none;">
<span>🛡️ 管理员视野</span>
<span x-text="showAdminView ? '▲' : '▼'" style="font-size: 10px; opacity: 0.6;"></span>
</div>
{{-- 折叠内容 --}}
<div x-show="showAdminView" x-transition
style="display: none; padding: 8px 10px; background: #fff5f5; border: 1px dashed #fca5a5;
border-top: none; border-radius: 0 0 8px 8px; font-size: 11px; color: #991b1b;">
<div style="display: flex; flex-direction: column; gap: 3px;">
<div><span style="opacity: 0.8;">首次IP</span><span x-text="userInfo.first_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">上次IP</span><span x-text="userInfo.last_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">本次IP</span><span x-text="userInfo.login_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">归属地:</span><span x-text="userInfo.location || '未知'"></span>
</div>
</div>
</div>
</div>
<div class="profile-detail" x-text="userInfo.sign || '这家伙很懒,什么也没留下'" style="margin-top: 12px;"></div>
{{-- 职务履历时间轴(有任职记录才显示,可折叠) --}}
<div x-show="userInfo.position_history && userInfo.position_history.length > 0"
style="display: none; margin-top: 10px; border-top: 1px solid #f0f0f0; padding-top: 8px;">
{{-- 可点击标题 --}}
<div x-on:click="showPositionHistory = !showPositionHistory"
style="display: flex; align-items: center; justify-content: space-between;
cursor: pointer; font-size: 11px; font-weight: bold; color: #7c3aed;
margin-bottom: 4px; user-select: none;">
<span>🎖️ 职务履历 <span style="font-weight: normal; font-size: 10px; color: #9ca3af;"
x-text="'' + (userInfo.position_history?.length ?? 0) + ' 条)'"></span></span>
<span x-text="showPositionHistory ? '▲' : '▼'" style="font-size: 10px; opacity:0.5;"></span>
</div>
{{-- 折叠内容 --}}
<div x-show="showPositionHistory" x-transition style="display: none;">
<template x-for="(h, idx) in userInfo.position_history" :key="idx">
<div style="display: flex; gap: 10px; margin-bottom: 8px; position: relative;">
{{-- 线 --}}
<div
style="display: flex; flex-direction: column; align-items: center; width: 18px; flex-shrink: 0;">
<div style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 2px;"
:style="h.is_active ? 'background: #7c3aed; box-shadow: 0 0 0 3px #ede9fe;' :
'background: #d1d5db;'">
</div>
<template x-if="idx < (userInfo.position_history?.length ?? 0) - 1">
<div style="width: 1px; flex: 1; background: #e5e7eb; margin-top: 2px;"></div>
</template>
</div>
{{-- 内容 --}}
<div style="flex: 1; font-size: 11px; padding-bottom: 4px;">
<div style="font-weight: bold; color: #374151;">
<span x-text="h.position_icon" style="margin-right: 2px;"></span>
<span
x-text="(h.department_name ? h.department_name + ' · ' : '') + h.position_name"></span>
<span x-show="h.is_active"
style="display: inline-block; margin-left: 4px; padding: 0 5px; background: #ede9fe; color: #7c3aed; border-radius: 10px; font-size: 10px;">在职中</span>
</div>
<div style="color: #9ca3af; font-size: 10px; margin-top: 2px;">
<span x-text="h.appointed_at"></span>
<span> </span>
<span x-text="h.is_active ? '至今' : (h.revoked_at || '')"></span>
<span style="margin-left: 4px;" x-text="'(' + h.duration_days + ' 天)'"></span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
{{-- 操作按钮区:加好友 + 送礼物 + 送金币(有职务且有奖励权限时显示) --}}
<div x-data="{ showGiftPanel: false, showRewardPanel: false }" x-show="userInfo.username !== window.chatContext.username">
<div class="modal-actions" style="margin-bottom: 0; display: flex; gap: 6px;">
{{-- 加好友 / 删好友 --}}
<button x-on:click="toggleFriend()" :disabled="friendLoading"
:style="is_friend
?
'background: #f1f5f9; color: #6b7280; border: 1px solid #d1d5db;' :
'background: linear-gradient(135deg,#16a34a,#22c55e); color:#fff; border:none;'"
style="flex:1; padding: 7px 10px; border-radius: 5px; font-size: 12px;
cursor: pointer; font-weight: bold; transition: opacity .15s;"
x-text="friendLoading ? '处理中…' : (is_friend ? '✅ 好友' : ' 加好友')"></button>
{{-- 送礼物按钮 --}}
<button class="btn-whisper" style="flex:1;"
x-on:click="showGiftPanel = !showGiftPanel; showRewardPanel = false;">
🎁 送礼物
</button>
{{-- 送金币按钮:有在职职务且 max_reward 不为 00=禁止,-1=不限,正数=有上限) --}}
<button x-show="window.chatContext?.myMaxReward !== 0"
style="flex:1; padding: 7px 10px; border-radius: 5px; font-size: 12px; font-weight: bold; cursor: pointer;
background: linear-gradient(135deg,#f59e0b,#d97706); color:#fff; border:none;"
x-on:click="openRewardModal(userInfo.username)">
💰 送金币
</button>
{{-- 求婚按钮:对方未婚 + 双方均已设置性别 + 异性 --}}
<button
x-show="!marriageLoading
&& (!targetMarriage || targetMarriage.status === 'none')
&& userInfo.sex
&& mySex
&& userInfo.sex !== mySex"
style="flex:1; padding: 7px 10px; border-radius: 5px; font-size: 12px; font-weight: bold; cursor: pointer;
background: linear-gradient(135deg,#f43f5e,#ec4899); color:#fff; border:none;"
x-on:click="showUserModal = false; openProposeModal(userInfo.username)">
💍 求婚
</button>
{{-- 自己未设置性别时的提示(让用户去完善资料) --}}
<div x-show="!marriageLoading
&& (!targetMarriage || targetMarriage.status === 'none')
&& userInfo.sex
&& !mySex
&& userInfo.sex !== mySex"
style="flex:1; display:flex; align-items:center; justify-content:center;
padding:7px 10px; border-radius:5px; font-size:11px;
background:#f8fafc; border:1px dashed #cbd5e1; color:#94a3b8; font-weight:bold; cursor:default;"
title="请到个人资料页设置您的性别后即可求婚">
💍 请先设置性别
</div>
{{-- 对方已婚时显示提示(非伴侣) --}}
<div x-show="!marriageLoading && targetMarriage && targetMarriage.status === 'married' && !targetMarriage.is_my_partner"
:title="'与 ' + (targetMarriage?.partner_name || '—') + ' 已婚'"
style="flex:1; display:flex; align-items:center; justify-content:center;
padding:7px 10px; border-radius:5px; font-size:12px; background:#fff1f2;
border:1px solid #fecdd3; color:#f43f5e; font-weight:bold; white-space:nowrap;">
💑 已婚
</div>
{{-- 如果对方是自己的伴侣,显示离婚按钮 --}}
<button
x-show="!marriageLoading && targetMarriage && targetMarriage.status === 'married' && targetMarriage.is_my_partner"
style="flex:1; padding: 7px 10px; border-radius: 5px; font-size: 12px; font-weight: bold; cursor: pointer;
background: #64748b; color:#fff; border:none;"
x-on:click="doDivorce(targetMarriage.marriage_id)">
💔 协议离婚
</button>
</div>
{{-- 内联礼物面板 --}}
<div x-show="showGiftPanel" x-transition
style="display: none;
padding: 12px 16px; background: #fff; border-top: 1px solid #f1f5f9;">
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-size: 13px; color: #334155; font-weight: bold;">🎁 选择礼物</span>
<button x-on:click="showGiftPanel = false"
style="background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 18px; line-height: 1;">×</button>
</div>
{{-- 礼物选择列表 --}}
<div
style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 12px; max-height: 180px; overflow-y: auto;">
<template x-for="g in gifts" :key="g.id">
<div x-on:click="selectedGiftId = g.id"
:style="selectedGiftId === g.id ?
'border-color: #f43f5e; background: #fff1f2; box-shadow: 0 4px 12px rgba(244,63,94,0.15);' :
'border-color: #e2e8f0; background: #fff;'"
style="border: 2px solid; padding: 8px 4px; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s;">
<img :src="'/images/gifts/' + g.image"
style="width: 36px; height: 36px; object-fit: contain; margin-bottom: 4px;"
:alt="g.name">
<div style="font-size: 11px; color: #1e293b; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
x-text="g.name"></div>
<div style="font-size: 10px; color: #e11d48;" x-text="g.cost + ' 💰'"></div>
</div>
</template>
</div>
{{-- 数量 + 送出 --}}
<div style="display: flex; gap: 8px; align-items: center;">
<select x-model.number="giftCount"
style="width: 70px; height: 36px; padding: 0 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; color: #334155;">
<option value="1">1 </option>
<option value="5">5 </option>
<option value="10">10 </option>
<option value="66">66 </option>
<option value="99">99 </option>
<option value="520">520 </option>
</select>
<button x-on:click="sendGift(); showGiftPanel = false;" :disabled="sendingGift"
style="flex:1; height: 36px; background: linear-gradient(135deg,#f43f5e,#be123c); color:#fff;
border: none; border-radius: 6px; font-size: 14px; font-weight: bold; cursor: pointer;"
:style="sendingGift ? 'opacity:0.7; cursor:not-allowed;' : ''">
<span x-text="sendingGift ? '正在送出...' : '💝 确认赠送'"></span>
</button>
</div>
</div>
</div>
{{-- 管理操作 + 职务操作 合并折叠区 --}}
@if (
$myLevel >= $levelWarn ||
$room->master == Auth::user()->username ||
Auth::user()->activePosition ||
$myLevel >= $superLevel)
<div style="padding: 0 16px 12px;" x-show="userInfo.username !== window.chatContext.username">
{{-- 折叠标题 --}}
<div x-on:click="showAdminPanel = !showAdminPanel"
style="display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; background: #fef2f2; border: 1px solid #fecaca;
border-radius: 6px; cursor: pointer; user-select: none;">
<span style="font-size: 11px; color: #c00; font-weight: bold;">🔧 管理操作</span>
<span x-text="showAdminPanel ? '▲' : '▼'"
style="font-size: 10px; color: #c00; opacity: 0.6;"></span>
</div>
{{-- 折叠内容 --}}
<div x-show="showAdminPanel" x-transition style="display: none; margin-top: 6px;">
@if ($myLevel >= $levelWarn || $room->master == Auth::user()->username)
<div x-show="userInfo.user_level < {{ $myLevel }}">
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
管理员操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;">
@if ($myLevel >= $levelWarn)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="warnUser()">⚠️ 警告</button>
@endif
@if ($myLevel >= $levelKick)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
x-on:click="kickUser()">🚫 踢出</button>
@endif
@if ($myLevel >= $levelMute)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
x-on:click="isMuting = !isMuting">🔇 禁言</button>
@endif
@if ($myLevel >= $levelFreeze)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #dbeafe; border: 1px solid #3b82f6; cursor: pointer;"
x-on:click="freezeUser()">🧊 冻结</button>
@endif
@if ($myLevel >= $superLevel)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;"
x-on:click="loadWhispers()">🔍 私信</button>
@endif
</div>
</div>
@endif
@if (Auth::user()->activePosition || $myLevel >= $superLevel)
<div>
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
职务操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<template x-if="!userInfo.position_name">
<button x-on:click="showAppointPanel = !showAppointPanel"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;">
任命职务</button>
</template>
<template x-if="userInfo.position_name">
<button x-on:click="doRevoke()" :disabled="appointLoading"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef9c3; border: 1px solid #eab308; cursor: pointer;">🔧
撤销职务</button>
</template>
</div>
<div x-show="showAppointPanel" x-transition
style="display:none; margin-top:8px; padding:10px; background:#faf5ff; border:1px solid #d8b4fe; border-radius:6px;">
<div style="font-size:11px; color:#7c3aed; margin-bottom:6px;">选择职务:</div>
<select x-model.number="selectedPositionId"
style="width:100%; padding:4px; border:1px solid #c4b5fd; border-radius:4px; font-size:11px; margin-bottom:6px;">
<template x-for="p in appointPositions" :key="p.id">
<option :value="p.id"
x-text="(p.icon?p.icon+' ':'')+p.department+' · '+p.name"></option>
</template>
</select>
<input type="text" x-model="appointRemark" placeholder="备注(如任命原因)"
style="width:100%; padding:4px; border:1px solid #c4b5fd; border-radius:4px; font-size:11px; box-sizing:border-box; margin-bottom:6px;">
<div style="display:flex; gap:6px;">
<button x-on:click="doAppoint()"
:disabled="appointLoading || !selectedPositionId"
style="flex:1; padding:5px; background:#7c3aed; color:#fff; border:none; border-radius:4px; font-size:11px; cursor:pointer;">
<span x-text="appointLoading?'处理中...':'✅ 确认任命'"></span>
</button>
<button x-on:click="showAppointPanel=false"
style="padding:5px 10px; background:#fff; border:1px solid #ccc; border-radius:4px; font-size:11px; cursor:pointer;">取消</button>
</div>
</div>
</div>
@endif
</div>{{-- /折叠内容 --}}
{{-- 禁言输入表单 --}}
<div x-show="isMuting" style="display:none; margin-top:6px;">
<div style="display:flex; gap:6px; align-items:center;">
<input type="number" x-model="muteDuration" min="1" max="1440"
placeholder="分钟"
style="width:60px; padding:4px; border:1px solid #ccc; border-radius:3px; font-size:11px;">
<span style="font-size:11px; color:#b86e00;">分钟</span>
<button x-on:click="muteUser()"
style="padding:4px 12px; background:#6366f1; color:#fff; border:none; border-radius:3px; font-size:11px; cursor:pointer;">执行</button>
</div>
</div>
</div>
@endif
{{-- 私信记录展示区(管理员查看) --}}
<div x-show="showWhispers"
style="display: none; padding: 0 16px 12px; max-height: 200px; overflow-y: auto;">
<div style="font-size: 11px; color: #666; margin-bottom: 4px;"
x-text="'最近 ' + whisperList.length + ' 条悄悄话:'"></div>
<template x-for="w in whisperList" :key="w.id">
<div style="font-size: 11px; padding: 3px 0; border-bottom: 1px solid #f0f0f0;">
<span style="color: #6366f1;" x-text="w.from_user"></span>
<span style="color: #059669;" x-text="w.to_user"></span>
<span x-text="w.content"></span>
<span style="color: #aaa; font-size: 10px;" x-text="'(' + w.sent_at + ')'"></span>
</div>
</template>
<div x-show="whisperList.length === 0" style="font-size: 11px; color: #aaa;">暂无悄悄话记录</div>
</div>
</div>
</div>
</div>
{{-- ═══════════ 奖励金币独立弹窗 ═══════════ --}}
<div id="reward-modal-container" x-data="{
show: false,
targetUsername: '',
amount: '',
sending: false,
loading: false,
quota: { max_once: null, daily_limit: null, today_sent: 0, daily_remaining: null, recent_rewards: [] },
fmt(v) {
if (v === null) return '不限';
if (v === 0) return '—';
return v.toLocaleString() + ' 金币';
},
async open(username) {
this.targetUsername = username;
this.amount = '';
this.sending = false;
this.loading = true;
this.show = true;
try {
const res = await fetch(window.chatContext.rewardQuotaUrl, {
headers: { 'Accept': 'application/json' }
});
this.quota = await res.json();
if (!this.quota.recent_rewards) this.quota.recent_rewards = [];
} catch { this.quota = { max_once: null, daily_limit: null, today_sent: 0, daily_remaining: null, recent_rewards: [] }; }
this.loading = false;
},
async send() {
if (this.sending) return;
const amt = parseInt(this.amount, 10);
if (!amt || amt <= 0) { window.chatDialog.alert('请输入有效金额', '提示', '#f59e0b'); return; }
const maxOnce = window.chatContext?.myMaxReward;
if (maxOnce === 0) { window.chatDialog.alert('你的职务没有奖励发放权限', '无权限', '#cc4444'); return; }
if (maxOnce > 0 && amt > maxOnce) { window.chatDialog.alert('超出单次上限 ' + maxOnce + ' 金币', '超出上限', '#cc4444'); return; }
this.sending = true;
try {
const res = await fetch(window.chatContext.rewardUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content || '',
'Accept': 'application/json',
},
body: JSON.stringify({ username: this.targetUsername, room_id: window.chatContext.roomId, amount: amt }),
});
const data = await res.json();
if (data.status === 'success') {
this.quota.today_sent += amt;
if (this.quota.daily_remaining !== null) {
this.quota.daily_remaining = Math.max(0, this.quota.daily_remaining - amt);
}
// 在历史记录头部插入
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mi = String(now.getMinutes()).padStart(2, '0');
this.quota.recent_rewards.unshift({ target: this.targetUsername, amount: amt, created_at: mm + '-' + dd + ' ' + hh + ':' + mi });
if (this.quota.recent_rewards.length > 10) this.quota.recent_rewards.pop();
this.amount = '';
window.chatDialog.alert(data.message, '🎉 奖励发放成功', '#d97706');
} else {
window.chatDialog.alert(data.message || '发放失败', '操作失败', '#cc4444');
}
} catch { window.chatDialog.alert('网络异常,请稍后重试', '错误', '#cc4444'); }
this.sending = false;
}
}">
<div x-show="show" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9900;"
x-on:click.self="show = false">
<div x-show="show"
style="display:none; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:520px; max-width:95vw; background:#fff; border-radius:16px;
box-shadow:0 20px 60px rgba(0,0,0,.25); overflow:hidden;">
{{-- 标题栏 --}}
<div
style="background:linear-gradient(135deg,#f59e0b,#d97706); padding:14px 20px;
display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="color:#fff; font-weight:bold; font-size:16px;">💰 发放奖励金币</div>
<div style="color:rgba(255,255,255,.9); font-size:12px; margin-top:2px;"
x-text="'发给:' + targetUsername"></div>
</div>
<button x-on:click="show = false"
style="background:rgba(255,255,255,.25); border:none; color:#fff; width:30px; height:30px;
border-radius:50%; cursor:pointer; font-size:18px; line-height:30px; text-align:center;">×</button>
</div>
{{-- 额度四格一行4列 --}}
<div style="padding:16px 20px 0;">
<div x-show="loading" style="text-align:center; color:#b45309; font-size:13px; padding:12px 0;">
加载额度信息…</div>
{{-- 注意x-show display:grid 必须分离,否则 x-show 显示时会把 display:grid 覆盖为 block --}}
<div x-show="!loading">
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:8px;">
<div
style="background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px; text-align:center;">
<div style="font-size:10px; color:#b45309; margin-bottom:4px;">单次上限</div>
<div style="font-size:13px; font-weight:bold; color:#92400e;"
x-text="fmt(quota.max_once)"></div>
</div>
<div
style="background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px; text-align:center;">
<div style="font-size:10px; color:#b45309; margin-bottom:4px;">单日上限</div>
<div style="font-size:13px; font-weight:bold; color:#92400e;"
x-text="fmt(quota.daily_limit)"></div>
</div>
<div
style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:8px; padding:10px; text-align:center;">
<div style="font-size:10px; color:#166534; margin-bottom:4px;">今日已发</div>
<div style="font-size:13px; font-weight:bold; color:#15803d;"
x-text="quota.today_sent.toLocaleString() + ' 金币'"></div>
</div>
<div
style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:8px; padding:10px; text-align:center;">
<div style="font-size:10px; color:#166534; margin-bottom:4px;">剩余额度</div>
<div style="font-size:13px; font-weight:bold; color:#15803d;"
x-text="fmt(quota.daily_remaining)"></div>
</div>
</div>
</div>
</div>
{{-- 输入区(同一行) --}}
<div style="padding:14px 20px 16px;">
<div style="display:flex; gap:10px; align-items:stretch; margin-bottom:8px;">
<input type="number" x-model.number="amount"
:placeholder="quota.max_once ? '最多 ' + quota.max_once + ' 金币' : '请输入发放金额'"
:max="quota.max_once || 999999" min="1" x-on:keydown.enter="send()"
style="flex:1; padding:9px 14px; border:2px solid #fcd34d;
border-radius:6px; font-size:13px; color:#92400e;
outline:none; box-sizing:border-box;">
<button x-on:click="send()"
style="padding:9px 20px; border:none; border-radius:20px;
font-size:13px; font-weight:bold; color:#fff;
cursor:pointer; transition:opacity .15s; white-space:nowrap;"
:style="(sending || !amount) ?
'background:#f59e0b; opacity:.45; cursor:not-allowed;' :
'background:#f59e0b; opacity:1;'"
:disabled="sending || !amount">
<span x-text="sending ? '发放中…' : '确认发放'"></span>
</button>
</div>
<p style="margin:0; font-size:10px; color:#b45309; opacity:.75; text-align:center;">
金币凭空产生并直接发放给对方,本操作记入你的履职记录。
</p>
</div>
{{-- 最近 10 条记录 --}}
<div style="padding:12px 20px 16px;">
<div
style="font-size:11px; color:#78716c; font-weight:bold; margin-bottom:6px; border-top:1px solid #f5f5f4; padding-top:10px;">
📋 最近发放记录
</div>
<div x-show="loading" style="font-size:11px; color:#aaa; text-align:center; padding:4px 0;">加载中…</div>
<div x-show="!loading && quota.recent_rewards.length === 0"
style="font-size:11px; color:#aaa; text-align:center; padding:4px 0;">暂无记录</div>
<div x-show="!loading" style="max-height:160px; overflow-y:auto;">
<template x-for="(r, i) in quota.recent_rewards" :key="i">
<div style="display:flex; justify-content:space-between; align-items:center;
font-size:11px; padding:4px 6px; border-radius:4px;"
:style="i % 2 === 0 ? 'background:#fafaf9;' : ''">
<span style="color:#57534e;">
<span style="color:#92400e; font-weight:bold;" x-text="r.target"></span>
</span>
<span style="color:#059669; font-weight:bold;"
x-text="'+' + r.amount.toLocaleString() + ' 金币'"></span>
<span style="color:#a8a29e;" x-text="r.created_at"></span>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<script>
/**
* openRewardModal - 全局入口:打开奖励金币独立弹窗
* @param {string} username 被奖励用户名
*/
function openRewardModal(username) {
const el = document.getElementById('reward-modal-container');
if (el) {
const data = Alpine.$data(el);
if (data) {
data.open(username);
}
}
}
</script>
{{-- ═══════════ 好友系统通知监听 ═══════════ --}}
{{-- 监听好友 WebSocket 事件,与好友操作逻辑集中在同一文件维护 --}}
<script>
// ── 好友系统私有频道监听(仅本人可见) ────────────────
/**
* 监听当前用户的私有频道 `user.{id}`
* 收到 FriendAdded / FriendRemoved 事件时用弹窗通知。
* FriendAdded → 居中大卡弹窗chatBanner 风格)
* FriendRemoved → 右下角 Toast 通知
*/
function setupFriendNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupFriendNotification, 500);
return;
}
const myId = window.chatContext.userId;
window.Echo.private(`user.${myId}`)
.listen('.FriendAdded', (e) => {
showFriendBanner(e.from_username, e.has_added_back);
})
.listen('.FriendRemoved', (e) => {
if (e.had_added_back) {
window.chatToast.show({
title: '好友通知',
message: `<b>${e.from_username}</b> 已将你从好友列表移除。<br><span style="color:#6b7280; font-size:12px;">你的好友列表中仍保留对方,可点击同步移除。</span>`,
icon: '👥',
color: '#6b7280',
duration: 10000,
action: {
label: `🗑️ 同步移除 ${e.from_username}`,
onClick: async () => {
const url = `/friend/${encodeURIComponent(e.from_username)}/remove`;
const csrf = document.querySelector('meta[name="csrf-token"]')
?.content ?? '';
await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json'
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
}
},
});
} else {
window.chatToast.show({
title: '好友通知',
message: `<b>${e.from_username}</b> 已将你从他的好友列表移除。`,
icon: '👥',
color: '#9ca3af',
});
}
});
}
document.addEventListener('DOMContentLoaded', setupFriendNotification);
// ── BannerNotification通用大卡片通知监听 ──────────────────
/**
* 监听 BannerNotification 事件,渲染 chatBanner 大卡片。
* 支持私有用户频道(单推)和房间频道(全员推送)。
*
* 安全说明BannerNotification 仅由后端可信代码 broadcast
* 私有频道需鉴权presence 频道需加入房间,均须服务端验证身份。
*/
function setupBannerNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupBannerNotification, 500);
return;
}
const myId = window.chatContext.userId;
const roomId = window.chatContext.roomId;
// 监听私有用户频道(单独推给某人)
window.Echo.private(`user.${myId}`)
.listen('.BannerNotification', (e) => {
if (e.options && typeof e.options === 'object') {
window.chatBanner.show(e.options);
}
});
// 监听房间频道(推给房间所有人)
if (roomId) {
window.Echo.join(`room.${roomId}`)
.listen('.BannerNotification', (e) => {
if (e.options && typeof e.options === 'object') {
window.chatBanner.show(e.options);
}
});
}
}
document.addEventListener('DOMContentLoaded', setupBannerNotification);
/**
* 显示好友添加居中大卡弹窗(使用 chatBanner 公共组件)。
* 互相好友 → 绿色渐变 + 互为好友文案
* 单向添加 → 蓝绿渐变 + 提示回加 + [ 回加好友] 按钮
*
* @param {string} fromUsername 添加者用户名
* @param {boolean} hasAddedBack 接收方是否已将添加者加为好友
*/
function showFriendBanner(fromUsername, hasAddedBack) {
if (hasAddedBack) {
window.chatBanner.show({
id: 'friend-banner',
icon: '🎉💚🎉',
title: '好友通知',
name: fromUsername,
body: '将你加为好友了!',
sub: '<strong style="color:#a7f3d0;">你们现在互为好友 🎊</strong>',
gradient: ['#065f46', '#059669', '#10b981'],
titleColor: '#a7f3d0',
autoClose: 5000,
});
} else {
window.chatBanner.show({
id: 'friend-banner',
icon: '💚📩',
title: '好友申请',
name: fromUsername,
body: '将你加为好友了!',
sub: '但你还没有回加对方为好友',
gradient: ['#1e3a5f', '#1d4ed8', '#0891b2'],
titleColor: '#bae6fd',
autoClose: 0,
buttons: [{
label: ' 回加好友',
color: '#10b981',
onClick: async (btn, close) => {
await quickFriendAction('add', fromUsername, btn);
if (btn.textContent.startsWith('✅')) {
setTimeout(close, 1500);
}
},
},
{
label: '稍后再说',
color: 'rgba(255,255,255,0.15)',
onClick: (btn, close) => close(),
},
],
});
}
}
/**
* 聊天区悄悄话内嵌链接的快捷好友操作。
* 由后端生成的 onclick="quickFriendAction('add'/'remove', username, this)" 调用。
*
* @param {string} act 'add' | 'remove'
* @param {string} username 目标用户名
* @param {HTMLElement} el 被点击的 <a> 元素,用于更新显示状态
*/
window.quickFriendAction = async function(act, username, el) {
if (el.dataset.done) {
return;
}
el.dataset.done = '1';
el.textContent = '处理中…';
el.style.pointerEvents = 'none';
try {
const method = act === 'add' ? 'POST' : 'DELETE';
const url = `/friend/${encodeURIComponent(username)}/${act === 'add' ? 'add' : 'remove'}`;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: window.chatContext?.roomId
}),
});
const data = await res.json();
if (data.status === 'success') {
el.textContent = act === 'add' ? '✅ 已回加' : '✅ 已移除';
el.style.color = '#16a34a';
el.style.textDecoration = 'none';
} else {
el.textContent = '❌ ' + (data.message || '操作失败');
el.style.color = '#cc4444';
}
} catch (e) {
el.textContent = '❌ 网络错误';
el.style.color = '#cc4444';
delete el.dataset.done;
el.style.pointerEvents = '';
}
};
</script>