Files
chatroom/resources/views/chat/partials/layout/toolbar.blade.php
lkddi 97e32572cf feat: 新增看视频赚金币功能
- 在右侧导航新增「赚钱」入口(娱乐下方)
- 新增 earn-panel 弹窗:风格与商店一致,800px 宽度
- 集成 FluidPlayer + VAST 广告(ExoClick)
- 动态倒计时:实时监听视频 duration/currentTime
- VAST 失败时自动回退保底视频,20s 超时保底放行
- 修复 AbortError:idle 时 video 不预播放,仅提供 fallback source
- 删除不支持的 player.on('error') 调用
- 所有 overlay 改用绝对定位居中,修复 Alpine x-show 破坏 flex 问题
- EarnController:Redis 每日 10 次限额 + 冷却防刷
- 领取成功后广播全服系统消息(含金币+经验+快捷入口标签)
- 移除神秘盒子相关 UI 代码
2026-04-02 18:35:54 +08:00

2008 lines
88 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. 工具条按钮(管理、商店、存点、头像、设置等)
2. 头像选择弹窗
3. 个人设置弹窗(密码、资料、密保)
4. 商店弹窗(从工具栏点击打开的全屏模态)
5. 对应的 JS 操作函数
依赖变量:$user, $superLevel, $room
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ═══════════ 竖向工具条按钮 ═══════════ --}}
<div class="chat-toolbar" id="toolbar-strip">
<div class="tool-btn" onclick="openShopModal()" title="购买道具">商店</div>
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
<div class="tool-btn" onclick="openGameHall()" title="娱乐游戏大厅">娱乐</div>
<div class="tool-btn" onclick="window.dispatchEvent(new CustomEvent('open-earn-panel'))" title="看视频赚金币">赚钱</div>
<div class="tool-btn" onclick="openBankModal()" title="银行存取金币">银行</div>
<div class="tool-btn" onclick="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
<div class="tool-btn" onclick="openAvatarPicker()" title="修改头像">头像</div>
<div class="tool-btn" onclick="document.getElementById('settings-modal').style.display='flex'" title="个人设置">设置
</div>
<div class="tool-btn" onclick="window.open('{{ route('feedback.index') }}', '_blank')" title="反馈">反馈</div>
<div class="tool-btn" onclick="window.open('{{ route('guestbook.index') }}', '_blank')" title="留言板/私信">留言</div>
<div class="tool-btn" onclick="window.open('{{ route('guide') }}', '_blank')" title="规则/帮助">规则</div>
@if ($user->id === 1 || $user->activePosition()->exists())
<div class="tool-btn" style="color: #ffcc00;" onclick="window.open('/admin', '_blank')" title="管理后台">管理</div>
<div class="tool-btn" onclick="window.open('{{ route('leaderboard.index') }}', '_blank')" title="排行榜">排行
</div>
@else
<div class="tool-btn" onclick="window.open('{{ route('leaderboard.index') }}', '_blank')" title="排行榜">排行
</div>
@endif
<div class="tool-btn" style="color: #ffaaaa;"
onclick="window.chatDialog.confirm('确定要离开聊天室吗?', '离开聊天室').then(ok => { if (ok) leaveRoom(); })" title="离开聊天室">
离开
</div>
</div>
{{-- ═══════════ 头像选择弹窗 ═══════════ --}}
<div id="avatar-picker-modal"
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:9999; justify-content:center; align-items:center;">
<div
style="background:#fff; width:600px; max-height:80vh; border-radius:6px; overflow:hidden;
box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column;">
{{-- 标题栏 --}}
<div
style="background:#336699; color:#fff; padding:10px 16px; font-size:14px; font-weight:bold;
display:flex; justify-content:space-between; align-items:center;">
<span>🖼 修改头像(原版风格)</span>
<span style="cursor:pointer; font-size:18px;" onclick="closeAvatarPicker()"></span>
</div>
{{-- 预览区 --}}
<div
style="padding:10px 16px; background:#f0f6ff; border-bottom:1px solid #ddd; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<span style="font-size:12px; color:#666;">当前选中:</span>
<img id="avatar-preview" src="{{ str_starts_with($user->usersf, 'storage/') ? '/' . $user->usersf : '/images/headface/' . ($user->usersf ?: '1.gif') }}"
style="width:40px; height:40px; border:2px solid #336699; border-radius:4px; object-fit: cover;">
<span id="avatar-selected-name" style="font-size:12px; color:#333;">{{ $user->usersf ?: '未设置' }}</span>
<button id="avatar-save-btn" disabled onclick="saveAvatar()"
style="margin-left:auto; padding:5px 16px; background:#336699; color:#fff; border:none;
border-radius:3px; font-size:12px; cursor:pointer;">确定更换系统头像</button>
<div style="width:100%; height:1px; background:#ddd; margin: 4px 0;"></div>
<div style="display:flex; align-items:center; gap:8px; width:100%;">
<span style="font-size:12px; color:#666; font-weight:bold;">自定义头像上传(112x112)</span>
<input type="file" id="avatar-upload-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;" onchange="handleAvatarUpload(this)">
<button id="avatar-upload-btn" onclick="document.getElementById('avatar-upload-input').click()"
style="padding:5px 16px; background:#16a34a; color:#fff; border:none;
border-radius:3px; font-size:12px; cursor:pointer;">选择本地图片上传</button>
</div>
</div>
{{-- 头像网格 --}}
<div id="avatar-grid"
style="flex:1; overflow-y:auto; padding:10px; display:flex; flex-wrap:wrap;
gap:4px; align-content:flex-start;">
</div>
</div>
</div>
{{-- ═══════════ 个人设置弹窗 ═══════════ --}}
<div id="settings-modal"
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:10000; justify-content:center; align-items:center;">
<div
style="background:#fff; border-radius:8px; width:380px; max-height:90vh;
box-shadow:0 8px 32px rgba(0,0,0,0.3); display:flex; flex-direction:column;">
{{-- --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;
padding:12px 16px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; flex-shrink:0;">
<span style="font-size:14px; font-weight:bold;">⚙️ 个人设置</span>
<span onclick="document.getElementById('settings-modal').style.display='none'"
style="cursor:pointer; font-size:18px; opacity:0.8;">&times;</span>
</div>
{{-- 内容滚动区:把 overflow-y:auto 放到内层,避免外层 overflow 创建堆叠上下文论clip全局弹窗 --}}
<div style="padding:16px; overflow-y:auto; flex:1;">
{{-- 修改密码 --}}
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">🔒 修改密码</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<input id="set-old-pwd" type="password" placeholder="当前旧密码"
style="padding:6px 8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<input id="set-new-pwd" type="password" placeholder="新密码至少6位"
style="padding:6px 8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<input id="set-new-pwd2" type="password" placeholder="确认新密码"
style="padding:6px 8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<div id="pwd-inline-msg"
style="display:none; border-radius:10px; padding:10px 14px;
text-align:center; margin-top:8px; font-size:12px; font-weight:bold;">
</div>
<button onclick="savePassword()"
style="padding:6px; background:#336699; color:#fff; border:none; border-radius:4px;
font-size:12px; cursor:pointer;">确定修改密码</button>
</div>
</div>
{{-- 个人资料 --}}
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">👤 个人资料</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<div style="display:flex; align-items:center; gap:8px;">
<label style="font-size:12px; width:50px; text-align:right;">性别:</label>
<select id="set-sex"
style="flex:1; padding:5px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<option value="1" {{ Auth::user()->sex == 1 ? 'selected' : '' }}></option>
<option value="2" {{ Auth::user()->sex == 2 ? 'selected' : '' }}></option>
<option value="0" {{ Auth::user()->sex == 0 ? 'selected' : '' }}>保密</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<label style="font-size:12px; width:50px; text-align:right;">邮箱:</label>
<input id="set-email" type="email" value="{{ Auth::user()->email ?? '' }}"
placeholder="用来找回密码(必填)"
style="flex:1; padding:5px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
</div>
@if (\App\Models\SysParam::where('alias', 'smtp_enabled')->value('body') === '1')
<div style="display:flex; align-items:center; gap:8px; margin-top:6px;">
<label style="font-size:12px; width:50px; text-align:right;">验证码:</label>
<input id="set-email-code" type="text" placeholder="修改邮箱时必填" maxlength="6"
style="flex:1; padding:5px; border:1px solid #ccc; border-radius:4px; font-size:12px; max-width:100px;">
<button id="btn-send-code" type="button" onclick="sendEmailCode()"
style="padding:5px 10px; border:1px solid #336699; background:#eef5ff; color:#336699; border-radius:4px; font-size:12px; cursor:pointer;">
获取验证码
</button>
</div>
@endif
</div>
</div>
{{-- 密保设置 --}}
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">🛡️ 密码保护</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<input id="set-question" type="text" value="{{ Auth::user()->question ?? '' }}"
placeholder="密保问题(如:我的小学名字?)"
style="padding:6px 8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<input id="set-answer" type="text" value="{{ Auth::user()->answer ?? '' }}"
placeholder="密保答案"
style="padding:6px 8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
</div>
</div>
{{-- 微信绑定 --}}
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">💬 微信绑定</div>
<div style="display:flex; flex-direction:column; gap:6px;" id="wechat-bind-container">
@if (empty(Auth::user()->wxid))
@php
$botConfigBody = \App\Models\SysParam::where('alias', 'wechat_bot_config')->value('body');
$botConfig = $botConfigBody ? json_decode($botConfigBody, true) : [];
$botWxid = $botConfig['kafka']['bot_wxid'] ?? '暂未配置';
$qrcodeImage = $botConfig['api']['qrcode_image'] ?? null;
@endphp
<div style="font-size:12px; color:#666;">
您尚未绑定微信。<br>
@if($qrcodeImage)
扫码添加机器人微信:<br>
<img src="{{ \Illuminate\Support\Facades\Storage::url($qrcodeImage) }}" alt="机器人二维码" style="max-height:100px; display:block; margin: 6px 0; border: 1px solid #ddd; border-radius: 4px;">
@else
请添加机器人微信:<strong style="color:#d97706">{{ $botWxid }}</strong><br>
@endif
并发送以下绑定代码完成绑定:
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input id="wechat-bind-code" type="text" readonly value="点击生成"
style="flex:1; padding:6px 8px; border:1px dashed #336699; background:#f9fafb; border-radius:4px; font-size:13px; font-weight:bold; color:#336699; text-align:center; cursor:text;">
<button type="button" id="btn-copy-bind-code" onclick="copyWechatBindCode()"
style="display:none; padding:5px 10px; border:1px solid #10b981; background:#ecfdf5; color:#10b981; border-radius:4px; font-size:12px; cursor:pointer; white-space:nowrap;">
复制
</button>
<button type="button" id="btn-generate-bind-code" onclick="generateWechatBindCode()"
style="padding:5px 10px; border:1px solid #336699; background:#eef5ff; color:#336699; border-radius:4px; font-size:12px; cursor:pointer; white-space:nowrap;">
生成代码
</button>
</div>
<div id="bind-code-tip" style="font-size:11px; color:#888; display:none; text-align:center;">有效时间 5 分钟,绑定成功后请刷新页面。</div>
@else
<div style="font-size:12px; color:#16a34a; font-weight:bold; display:flex; justify-content:space-between; align-items:center;">
<span>已绑定微信,可接收提醒通知。</span>
<button type="button" onclick="unbindWechat()" style="padding:4px 8px; background:#fee2e2; color:#dc2626; border:1px solid #fecaca; border-radius:4px; font-size:11px; cursor:pointer;">解除绑定</button>
</div>
@endif
</div>
</div>
{{-- 内联操作结果提示(仿百家乐已押注卡片风格) --}}
<div id="settings-inline-msg"
style="display:none; border-radius:10px; padding:10px 14px;
text-align:center; margin-bottom:10px; font-size:13px; font-weight:bold;">
</div>
{{-- 保存按钮 --}}
<button onclick="saveSettings()"
style="width:100%; padding:8px; background:linear-gradient(135deg,#336699,#5a8fc0);
color:#fff; border:none; border-radius:4px; font-size:13px; font-weight:bold; cursor:pointer;">
💾 保存资料设置
</button>
</div>
</div>
</div>
{{-- ═══════════ 好友面板(独立文件)═══════════ --}}
@include('chat.partials.friend-panel')
{{-- ═══════════ 娱乐游戏大厅弹窗games/ 子目录)═══════════ --}}
@include('chat.partials.games.game-hall')
{{-- ═══════════ 工具条相关 JS 函数 ═══════════ --}}
<script>
// ── 头像选择器(与上方 #avatar-picker-modal DOM 对应)──────────────
let avatarPickerLoaded = false;
/**
* 打开头像选择弹窗
*/
function openAvatarPicker() {
const modal = document.getElementById('avatar-picker-modal');
modal.style.display = 'flex';
if (!avatarPickerLoaded) {
loadHeadfaces();
avatarPickerLoaded = true;
}
}
/**
* 关闭头像选择弹窗
*/
function closeAvatarPicker() {
document.getElementById('avatar-picker-modal').style.display = 'none';
}
/**
* 加载头像列表(懒加载,首次打开时请求)
*/
async function loadHeadfaces() {
const grid = document.getElementById('avatar-grid');
grid.innerHTML = '<div style="text-align:center;padding:20px;color:#999;">加载中...</div>';
try {
const res = await fetch('/headface/list');
const data = await res.json();
grid.innerHTML = '';
data.headfaces.forEach(file => {
const img = document.createElement('img');
img.src = '/images/headface/' + file;
img.className = 'avatar-option';
img.title = file;
img.dataset.file = file;
img.onerror = () => img.style.display = 'none';
img.onclick = () => selectAvatar(file, img);
grid.appendChild(img);
});
} catch (e) {
grid.innerHTML = '<div style="text-align:center;padding:20px;color:red;">加载失败</div>';
}
}
/**
* 选中一个头像(高亮选中状态)
*
* @param {string} file 头像文件名
* @param {HTMLElement} imgEl 被点击的 img 元素
*/
function selectAvatar(file, imgEl) {
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
imgEl.classList.add('selected');
document.getElementById('avatar-preview').src = '/images/headface/' + file;
document.getElementById('avatar-selected-name').textContent = file;
document.getElementById('avatar-save-btn').disabled = false;
document.getElementById('avatar-save-btn').dataset.file = file;
}
/**
* 处理本地头像上传
*/
async function handleAvatarUpload(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
// 简单的前端校验
if (file.size > 2 * 1024 * 1024) {
window.chatDialog.alert('图片大小不可超过 2MB', '上传失败', '#cc4444');
input.value = '';
return;
}
const btn = document.getElementById('avatar-upload-btn');
btn.disabled = true;
btn.textContent = '上传中...';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/headface/upload', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: formData
});
const data = await res.json();
if (res.ok && data.status === 'success') {
window.chatDialog.alert('自定义头像上传成功!', '提示', '#16a34a');
// 更新预览图和显示名称
const previewImg = document.getElementById('avatar-preview');
const relativeUrl = '/' + data.headface;
previewImg.src = relativeUrl;
document.getElementById('avatar-selected-name').textContent = data.headface;
// 同步在线列表自己
const myName = window.chatContext.username;
if (typeof onlineUsers !== 'undefined' && onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
if (typeof renderUserList === 'function') {
renderUserList();
}
// 清除系统头像选中状态
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
document.getElementById('avatar-save-btn').disabled = true;
closeAvatarPicker();
} else {
window.chatDialog.alert(data.message || '上传失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('网络错误,上传失败', '网络异常', '#cc4444');
}
btn.disabled = false;
btn.textContent = '选择本地图片上传';
input.value = ''; // 清空 file input允许重复选中同一文件
}
/**
* 保存选中的头像(调用 API 更新,成功后刷新用户列表)
*/
async function saveAvatar() {
const btn = document.getElementById('avatar-save-btn');
const file = btn.dataset.file;
if (!file) {
return;
}
btn.disabled = true;
btn.textContent = '保存中...';
try {
const res = await fetch('/headface/change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({
headface: file
})
});
const data = await res.json();
if (data.status === 'success') {
window.chatDialog.alert('头像修改成功!', '提示', '#16a34a');
// 同步更新内存中的在线用户头像,避免重新渲染前闪烁旧图
const myName = window.chatContext.username;
if (typeof onlineUsers !== 'undefined' && onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
if (typeof renderUserList === 'function') {
renderUserList();
}
closeAvatarPicker();
} else {
window.chatDialog.alert(data.message || '修改失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('网络错误', '网络异常', '#cc4444');
}
btn.disabled = false;
btn.textContent = '确定更换';
}
/**
* 保存密码(调用修改密码 API
*/
/**
* 显示内联操作结果提示仿百家乐「已押注」卡片风格3s 后自动消失)
*
* @param {string} elId 目标元素 ID
* @param {string} message 提示内容
* @param {boolean} success 是否成功(决定颜色)
*/
function showInlineMsg(elId, message, success) {
const el = document.getElementById(elId);
if (!el) return;
if (success) {
el.style.background = '#f0fdf4';
el.style.border = '1px solid #86efac';
el.style.color = '#16a34a';
} else {
el.style.background = '#fff5f5';
el.style.border = '1px solid #fecaca';
el.style.color = '#dc2626';
}
el.textContent = message;
el.style.display = 'block';
el.style.opacity = '1';
el.style.transition = 'opacity .4s';
clearTimeout(el._hideTimer);
el._hideTimer = setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => {
el.style.display = 'none';
}, 420);
}, 3000);
}
/**
* 保存密码(调用修改密码 API
*/
async function savePassword() {
const oldPwd = document.getElementById('set-old-pwd').value;
const newPwd = document.getElementById('set-new-pwd').value;
const newPwd2 = document.getElementById('set-new-pwd2').value;
if (!oldPwd || !newPwd) {
showInlineMsg('pwd-inline-msg', '⚠️ 请填写旧密码和新密码', false);
return;
}
if (newPwd.length < 6) {
showInlineMsg('pwd-inline-msg', '⚠️ 新密码最少6位', false);
return;
}
if (newPwd !== newPwd2) {
showInlineMsg('pwd-inline-msg', '⚠️ 两次输入的新密码不一致!', false);
return;
}
try {
const res = await fetch('{{ route('user.update_password') }}', {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
old_password: oldPwd,
new_password: newPwd,
new_password_confirmation: newPwd2
})
});
const data = await res.json();
if (res.ok && data.status === 'success') {
showInlineMsg('pwd-inline-msg', '🔒 密码修改成功!', true);
document.getElementById('set-old-pwd').value = '';
document.getElementById('set-new-pwd').value = '';
document.getElementById('set-new-pwd2').value = '';
} else {
showInlineMsg('pwd-inline-msg', '❌ ' + (data.message || '请输入正确的旧密码'), false);
}
} catch (e) {
showInlineMsg('pwd-inline-msg', '🌐 网络异常,请稍后重试', false);
}
}
/**
* 保存个人资料和密保设置
*/
async function saveSettings() {
const profileData = {
sex: document.getElementById('set-sex').value,
email: document.getElementById('set-email').value,
email_code: document.getElementById('set-email-code') ? document.getElementById('set-email-code')
.value : '',
question: document.getElementById('set-question').value,
answer: document.getElementById('set-answer').value,
headface: @json(Auth::user()->usersf ?: '1.gif'),
sign: @json(Auth::user()->sign ?? '')
};
try {
const res = await fetch('{{ route('user.update_profile') }}', {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(profileData)
});
const data = await res.json();
if (res.ok && data.status === 'success') {
showInlineMsg('settings-inline-msg', '✅ 资料保存成功!', true);
} else {
showInlineMsg('settings-inline-msg', '❌ ' + (data.message || '输入有误'), false);
}
} catch (e) {
showInlineMsg('settings-inline-msg', '🌐 网络异常,请稍后重试', false);
}
}
/**
* 发送邮箱验证码 (带有 60s 倒计时机制防灌水)
*/
async function sendEmailCode() {
const emailInput = document.getElementById('set-email').value.trim();
if (!emailInput) {
window.chatDialog.alert('请先填写邮箱地址后再获取验证码!', '提示', '#d97706');
return;
}
const btn = document.getElementById('btn-send-code');
btn.disabled = true;
btn.innerText = '正在发送...';
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
try {
const res = await fetch('{{ route('user.send_email_code') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
email: emailInput
})
});
const data = await res.json();
if (res.ok && data.status === 'success') {
window.chatDialog.alert(data.message || '验证码发送成功请前往邮箱查收有效期5分钟', '发送成功', '#16a34a');
// 开始 60 秒防暴力点击倒计时
let count = 60;
btn.innerText = count + 's 后重试';
const timer = setInterval(() => {
count--;
if (count <= 0) {
clearInterval(timer);
btn.innerText = '获取验证码';
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
} else {
btn.innerText = count + 's 后重试';
}
}, 1000);
} else {
window.chatDialog.alert('发送失败:' + (data.message || '系统繁忙'), '发送失败', '#dc2626');
// 失败了立刻解除禁用以重新尝试
btn.innerText = '获取验证码';
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
}
} catch (e) {
window.chatDialog.alert('网络异常,验证码发送失败,请稍后重试。', '错误', '#6b7280');
btn.innerText = '获取验证码';
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
}
}
/**
* 生成微信绑定验证码
*/
async function generateWechatBindCode() {
const btn = document.getElementById('btn-generate-bind-code');
const input = document.getElementById('wechat-bind-code');
const tip = document.getElementById('bind-code-tip');
btn.disabled = true;
btn.innerText = '生成中...';
try {
const res = await fetch('{{ route('user.generate_wechat_code') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
if (res.ok && data.status === 'success') {
input.value = data.code;
tip.style.display = 'block';
document.getElementById('btn-copy-bind-code').style.display = 'inline-block';
showInlineMsg('settings-inline-msg', '✅ 绑定代码生成成功请在5分钟内发送给机器人', true);
} else {
showInlineMsg('settings-inline-msg', '❌ 生成失败:' + (data.message || '未知错误'), false);
}
} catch (e) {
showInlineMsg('settings-inline-msg', '🌐 网络异常,请稍后重试', false);
}
btn.disabled = false;
btn.innerText = '重新生成';
}
/**
* 复制微信绑定验证码
*/
function copyWechatBindCode() {
const input = document.getElementById('wechat-bind-code');
if (input.value && input.value !== '点击生成' && input.value !== '生成中...') {
input.select();
input.setSelectionRange(0, 99999);
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(input.value);
} else {
document.execCommand('copy');
}
const btn = document.getElementById('btn-copy-bind-code');
const originalText = btn.innerText;
btn.innerText = '已复制';
setTimeout(() => {
btn.innerText = originalText;
}, 2000);
} catch (err) {
showInlineMsg('settings-inline-msg', '❌ 复制失败,请手动复制', false);
}
}
}
/**
* 解除微信绑定
*/
async function unbindWechat() {
if (!confirm('确定要解除微信绑定吗?解除后将无法接收任何机器人推送通知。')) {
return;
}
try {
const res = await fetch('{{ route('user.unbind_wechat') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
if (res.ok && data.status === 'success') {
alert('✅ 解绑成功!请刷新页面获取最新状态。');
location.reload();
} else {
alert('❌ 解绑失败:' + (data.message || '未知错误'));
}
} catch (e) {
alert('网络异常,解绑失败');
}
}
</script>
{{-- ═══════════ 商店弹窗 ═══════════ --}}
<style>
/* 商店弹窗遮罩 */
#shop-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9999;
justify-content: center;
align-items: center;
}
/* 弹窗主体 — 与设置弹窗同风格 */
#shop-modal-inner {
background: #fff;
border-radius: 8px;
width: 800px;
max-width: 95vw;
max-height: 84vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
overflow: hidden;
position: relative;
}
/* 标题栏 — 与设置/头像弹窗一致 */
#shop-modal-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#shop-modal-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#shop-modal-jjb {
font-size: 12px;
color: #d0e8ff;
display: flex;
align-items: center;
gap: 3px;
background: rgba(0, 0, 0, .2);
padding: 2px 8px;
border-radius: 10px;
}
#shop-modal-jjb strong {
color: #ffe082;
font-size: 13px;
}
#shop-week-badge {
display: none;
font-size: 10px;
background: rgba(255, 255, 255, .2);
padding: 2px 7px;
border-radius: 10px;
color: #fff;
}
#shop-modal-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#shop-modal-close:hover {
opacity: 1;
}
/* Toast */
#shop-toast {
display: none;
margin: 6px 12px 0;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
/* 商品列表区 */
#shop-items-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
align-content: start;
background: #f6faff;
}
/* 分组标题 — 独占一整行 */
.shop-group-header {
grid-column: 1 / -1;
font-size: 11px;
font-weight: bold;
color: #336699;
padding: 6px 4px 4px;
border-bottom: 1px solid #cde;
display: flex;
align-items: baseline;
gap: 6px;
}
.shop-group-header span {
font-size: 10px;
color: #888;
font-weight: normal;
}
/* 商品卡 */
.shop-card {
background: #fff;
border: 1px solid #d0e4f5;
border-radius: 6px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 5px;
transition: border-color .2s, box-shadow .2s;
cursor: default;
}
.shop-card:hover {
border-color: #5a8fc0;
box-shadow: 0 2px 8px rgba(51, 102, 153, .18);
}
.shop-card-top {
display: flex;
align-items: center;
gap: 6px;
}
.shop-card-icon {
font-size: 20px;
flex-shrink: 0;
}
.shop-card-name {
font-size: 12px;
font-weight: bold;
color: #225588;
flex: 1;
line-height: 1.3;
}
.shop-card-desc {
font-size: 10px;
color: #888;
line-height: 1.4;
flex: 1;
}
.shop-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
padding: 5px 8px;
cursor: pointer;
font-size: 11px;
font-weight: bold;
width: 100%;
margin-top: auto;
transition: opacity .15s;
}
.shop-btn:hover {
opacity: .85;
}
.shop-btn-use {
background: linear-gradient(135deg, #7c3aed, #9f67e8);
}
/* 改名内嵌遮罩 */
#shop-rename-overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 10001;
justify-content: center;
align-items: center;
border-radius: 8px;
}
#shop-rename-box {
background: #fff;
border: 1px solid #5a8fc0;
border-radius: 8px;
padding: 16px 14px;
width: 230px;
box-shadow: 0 4px 20px rgba(0, 0, 0, .25);
}
#shop-rename-box h4 {
font-size: 13px;
font-weight: bold;
color: #336699;
margin: 0 0 10px;
}
#rename-input {
width: 100%;
border: 1px solid #aac;
border-radius: 4px;
padding: 6px 8px;
color: #333;
font-size: 12px;
box-sizing: border-box;
margin-bottom: 8px;
outline: none;
}
#rename-input:focus {
border-color: #336699;
}
.rename-btn-row {
display: flex;
gap: 6px;
}
#rename-confirm {
flex: 1;
background: #336699;
color: #fff;
border: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
}
#rename-cancel {
flex: 1;
background: #eee;
color: #555;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px;
cursor: pointer;
font-size: 12px;
}
#rename-err {
color: #c00;
font-size: 10px;
margin-top: 5px;
min-height: 14px;
}
/* 送礼对话框 */
#gift-dialog {
display: none;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .55);
z-index: 10001;
justify-content: center;
align-items: center;
border-radius: 8px;
}
#gift-dialog-box {
background: #fff;
border: 1px solid #5a8fc0;
border-radius: 8px;
padding: 16px 14px;
width: 250px;
box-shadow: 0 4px 20px rgba(0, 0, 0, .25);
}
#gift-dialog-box h4 {
font-size: 12px;
font-weight: bold;
color: #336699;
margin: 0 0 3px;
}
#gift-item-name {
font-size: 11px;
color: #555;
display: block;
border-bottom: 1px dashed #cde;
padding-bottom: 6px;
margin-bottom: 10px;
}
.gift-label {
font-size: 11px;
color: #555;
margin-bottom: 3px;
display: block;
}
.gift-input {
width: 100%;
border: 1px solid #aac;
border-radius: 4px;
padding: 5px 8px;
font-size: 12px;
box-sizing: border-box;
margin-bottom: 8px;
outline: none;
}
.gift-input:focus {
border-color: #336699;
}
.gift-hint {
font-size: 10px;
color: #999;
margin: -4px 0 8px;
display: block;
}
#gift-err {
color: #c00;
font-size: 10px;
margin-top: 4px;
min-height: 14px;
}
</style>
<div id="shop-modal">
<div id="shop-modal-inner" style="position:relative;">
{{-- 标题栏 --}}
<div id="shop-modal-header">
<div id="shop-modal-title">🛍 道具商店</div>
<div id="shop-modal-jjb">💰 <strong id="shop-jjb">--</strong> 金币</div>
<span id="shop-week-badge"></span>
<span id="shop-modal-close" onclick="closeShopModal()"></span>
</div>
{{-- Toast --}}
<div id="shop-toast"></div>
{{-- 商品网格 --}}
<div id="shop-items-list">
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
</div>
{{-- 改名内嵌遮罩 --}}
<div id="shop-rename-overlay">
<div id="shop-rename-box">
<h4>🎭 使用改名卡</h4>
<input id="rename-input" type="text" maxlength="10" placeholder="输入新昵称1-10字">
<div class="rename-btn-row">
<button id="rename-confirm" onclick="submitRename()">确认改名</button>
<button id="rename-cancel" onclick="closeRenameModal()">取消</button>
</div>
<div id="rename-err"></div>
</div>
</div>
{{-- 送礼对话框:填写接收人 + 赠言 --}}
<div id="gift-dialog">
<div id="gift-dialog-box">
<h4>🎁 赠出单次特效卡</h4>
<span id="gift-item-name"></span>
<label class="gift-label">送给谁?</label>
<input id="gift-recipient" class="gift-input" type="text" maxlength="20"
placeholder="用户名(留空 = 全场可见)">
<span class="gift-hint">💡 留空表示所有人;购买者必定可见</span>
<label class="gift-label">说一句话(公屏发送)</label>
<input id="gift-message" class="gift-input" type="text" maxlength="50" placeholder="可不填,百字以内">
<div id="gift-err"></div>
<div class="rename-btn-row" style="margin-top:8px;">
<button onclick="confirmGift()"
style="flex:1;background:#336699;color:#fff;border:none;border-radius:4px;padding:7px;cursor:pointer;font-size:12px;font-weight:bold;">确认赠出</button>
<button onclick="closeGiftDialog()"
style="flex:1;background:#eee;color:#555;border:1px solid #ccc;border-radius:4px;padding:7px;cursor:pointer;font-size:12px;">取消</button>
</div>
</div>
</div>
</div>
</div>
<script>
/**
* 商店弹窗逻辑
* 工具栏点击「商店」按钮触发,全屏模态展示
*/
(function() {
let shopLoaded = false;
/** 打开商店弹窗 */
window.openShopModal = function() {
document.getElementById('shop-modal').style.display = 'flex';
if (!shopLoaded) {
shopLoaded = true;
fetchShopData();
}
};
/** 关闭商店弹窗 */
window.closeShopModal = function() {
document.getElementById('shop-modal').style.display = 'none';
};
// 点击遮罩层关闭弹窗
document.getElementById('shop-modal').addEventListener('click', function(e) {
if (e.target === this) closeShopModal();
});
/** 拉取商品数据 */
function fetchShopData() {
fetch('{{ route('shop.items') }}', {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': _csrf()
}
})
.then(r => r.json())
.then(data => renderShop(data))
.catch(() => showShopToast('⚠ 加载失败,请重试', false));
}
/** 渲染商品列表2列网格 */
function renderShop(data) {
// 更新余额
document.getElementById('shop-jjb').textContent = Number(data.user_jjb).toLocaleString();
// 周卡状态
const badge = document.getElementById('shop-week-badge');
if (data.active_week_effect) {
const icons = {
fireworks: '🎆',
rain: '🌧',
lightning: '⚡',
snow: '❄️'
};
badge.textContent = (icons[data.active_week_effect] ?? '') + ' 周卡生效中';
badge.style.display = 'inline-block';
}
const ringCounts = data.ring_counts || {};
const groups = [{
label: '⚡ 单次特效卡',
desc: '立即播放一次,仅自己可见',
type: 'instant'
},
{
label: '📅 周卡 7天登录自动播放',
desc: '同时只能激活一种,购新旧失效不退款',
type: 'duration'
},
{
label: '💍 求婚戒指',
desc: '存入背包,求婚时消耗(被拒则遗失)',
type: 'ring'
},
{
label: '🎣 自动钓鱼卡',
desc: '激活后自动收篼,无需手动点击浮漂',
type: 'auto_fishing'
},
{
label: '🎭 道具',
desc: '',
type: 'one_time'
},
];
const list = document.getElementById('shop-items-list');
list.innerHTML = '';
groups.forEach(g => {
const items = data.items.filter(i => i.type === g.type);
if (!items.length) return;
// 分组标题(独占一整行)
const header = document.createElement('div');
header.className = 'shop-group-header';
// 分组标题徽章:
// - auto_fishing显示剩余时间紫色
// - duration显示当前已激活的周卡名称绿色
let groupSuffix = '';
if (g.type === 'auto_fishing' && (data.auto_fishing_minutes_left || 0) > 0) {
const left = data.auto_fishing_minutes_left;
const leftStr = left >= 60 ? Math.floor(left / 60) + ' 小时' : left + ' 分钟';
groupSuffix =
` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#7c3aed;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">⏳ 剩余 ${leftStr}</span>`;
} else if (g.type === 'duration' && data.active_week_effect) {
// active_week_effect 是 effectKey 字符串,从 items 列表反查对应商品名称
const effKey = data.active_week_effect;
const effItem = data.items.find(i => i.type === 'duration' && i.slug.includes(effKey));
const effName = effItem ? effItem.name : effKey;
groupSuffix =
` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#16a34a;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">✅ 已激活:${effName}</span>`;
}
header.innerHTML = `${g.label}${groupSuffix}${g.desc ? ` <span>${g.desc}</span>` : ''}`;
list.appendChild(header);
items.forEach(item => {
const isRename = item.slug === 'rename_card';
const canUse = isRename && data.has_rename_card;
const isRing = item.type === 'ring';
const ownedQty = isRing ? (ringCounts[item.id] || 0) : 0;
const card = document.createElement('div');
card.className = 'shop-card';
// 顶部:图标 + 名称(戒指加持有数徽章,其余正常显示)
const isAutoFishing = item.type === 'auto_fishing';
let iconHtml;
if (isRing && ownedQty > 0) {
iconHtml = `<span style="position:relative;display:inline-block;">
<span class="shop-card-icon">${item.icon}</span>
<span style="position:absolute;top:-4px;right:-6px;background:#f43f5e;color:#fff;font-size:9px;font-weight:800;min-width:15px;height:15px;border-radius:8px;text-align:center;line-height:15px;padding:0 2px;">${ownedQty}</span>
</span>`;
} else {
iconHtml = `<span class="shop-card-icon">${item.icon}</span>`;
}
const durationLabel = isAutoFishing && item.duration_minutes > 0 ?
`<div style="font-size:9px;margin-top:3px;color:#7c3aed;">⏱ 有效期 ${item.duration_minutes >= 60 ? Math.floor(item.duration_minutes / 60) + ' 小时' : item.duration_minutes + ' 分钟'}</div>` :
'';
card.innerHTML = `
<div class="shop-card-top">
${iconHtml}
<span class="shop-card-name">${item.name}</span>
</div>
<div class="shop-card-desc">${item.description ?? ''}</div>
${isRing && (item.intimacy_bonus > 0 || item.charm_bonus > 0) ? `
<div style="font-size:9px;margin-top:3px;display:flex;gap:8px;">
${item.intimacy_bonus > 0 ? `<span style="color:#f43f5e;">💞 亲密 +${item.intimacy_bonus}</span>` : ''}
${item.charm_bonus > 0 ? `<span style="color:#a855f7;">✨ 魅力 +${item.charm_bonus}</span>` : ''}
</div>` : ''}
${durationLabel}
`;
// 按钮
const btn = document.createElement('button');
if (canUse) {
btn.className = 'shop-btn shop-btn-use';
btn.textContent = '✦ 使用改名卡';
btn.onclick = openRenameModal;
} else if (item.type === 'instant') {
// 单次卡:打开送礼弹框
btn.className = 'shop-btn';
btn.innerHTML = `💰 ${Number(item.price).toLocaleString()}`;
btn.onclick = () => openGiftDialog(item);
} else {
// 周卡、道具、戒指、自动钓鱼卡:弹确认窗口再购买
btn.className = 'shop-btn';
btn.innerHTML = `💰 ${Number(item.price).toLocaleString()}`;
btn.onclick = async () => {
const confirmMsg =
`确认花费 💰 ${Number(item.price).toLocaleString()} 金币购买\n【${item.name}】吗?`;
const ok = await window.chatDialog.confirm(confirmMsg, '确认购买');
if (ok) buyItem(item.id, item.name, item.price, 'all', '');
};
}
card.appendChild(btn);
list.appendChild(card);
});
});
}
/**
* 打开送礼弹框(仅单次特效卡)
* 让用户填写:送给谁 + 说一句话
*/
let _giftItem = null;
window.openGiftDialog = function(item) {
_giftItem = item;
// 重置内容
document.getElementById('gift-recipient').value = '';
document.getElementById('gift-message').value = '';
document.getElementById('gift-err').textContent = '';
document.getElementById('gift-item-name').textContent =
`${item.icon} ${item.name}(💰 ${Number(item.price).toLocaleString()}`;
document.getElementById('gift-dialog').style.display = 'flex';
};
window.closeGiftDialog = function() {
document.getElementById('gift-dialog').style.display = 'none';
_giftItem = null;
};
/** 送礼确认,提交购买 */
window.confirmGift = function() {
if (!_giftItem) return;
const recipient = document.getElementById('gift-recipient').value.trim();
const message = document.getElementById('gift-message').value.trim();
document.getElementById('gift-err').textContent = '';
// 先将商品数据保存到局部变量再关闭弹框closeGiftDialog 会把 _giftItem 设为 null
const item = _giftItem;
closeGiftDialog();
buyItem(item.id, item.name, item.price, recipient, message);
};
/** 购买商品(最终执行) */
window.buyItem = function(itemId, name, price, recipient, message) {
const roomId = window.chatContext?.roomId ?? 0;
fetch('{{ route('shop.buy') }}', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': _csrf()
},
body: JSON.stringify({
item_id: itemId,
recipient: recipient || 'all',
message: message || '',
room_id: roomId,
}),
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
// 更新金币
if (data.jjb !== undefined)
document.getElementById('shop-jjb').textContent = Number(data.jjb)
.toLocaleString();
// 购买成功提示
showShopToast(`✅ ${name} 购买成功!`, true);
// 播放本地特效(购买者自己必须也能看到)
if (data.play_effect && window.EffectManager) {
window.EffectManager.play(data.play_effect);
}
// 延迟刷新商品数据(保持商店开着让用户看到状态更新)
shopLoaded = false;
setTimeout(() => {
fetchShopData();
shopLoaded = true;
}, 1000);
} else {
showShopToast(data.message, false);
}
})
.catch(() => showShopToast('⚠ 网络异常,请重试', false));
};
/** Toast 通知 */
window.showShopToast = function(msg, ok) {
const el = document.getElementById('shop-toast');
el.textContent = msg;
el.style.display = 'block';
el.style.background = ok ? '#064e3b' : '#7f1d1d';
el.style.color = ok ? '#6ee7b7' : '#fca5a5';
clearTimeout(el._t);
el._t = setTimeout(() => {
el.style.display = 'none';
}, 3500);
};
/** 打开改名框 */
window.openRenameModal = function() {
const m = document.getElementById('shop-rename-overlay');
m.style.display = 'flex';
document.getElementById('rename-input').focus();
document.getElementById('rename-err').textContent = '';
};
/** 关闭改名框 */
window.closeRenameModal = function() {
document.getElementById('shop-rename-overlay').style.display = 'none';
};
/** 提交改名 */
window.submitRename = function() {
const newName = document.getElementById('rename-input').value.trim();
if (!newName) {
document.getElementById('rename-err').textContent = '请输入新昵称';
return;
}
fetch('{{ route('shop.rename') }}', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': _csrf()
},
body: JSON.stringify({
new_name: newName
}),
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
closeRenameModal();
showShopToast(data.message, true);
shopLoaded = false;
setTimeout(() => window.location.reload(), 2000);
} else {
document.getElementById('rename-err').textContent = data.message;
}
})
.catch(() => {
document.getElementById('rename-err').textContent = '网络异常,请重试';
});
};
function _csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
}
})();
</script>
{{-- ═══════════ 婚姻状态弹窗 ═══════════ --}}
<div id="marriage-status-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.5);
z-index:9999; justify-content:center; align-items:center;">
<div
style="background:#fff; border-radius:10px; width:360px; max-width:94vw;
box-shadow:0 12px 40px rgba(0,0,0,.3); overflow:hidden;
animation:gdSlideIn .18s ease; display:flex; flex-direction:column;">
{{-- 标题栏 --}}
<div
style="background:linear-gradient(135deg,#be185d,#f43f5e,#ec4899);
color:#fff; padding:12px 16px;
display:flex; align-items:center; justify-content:space-between;">
<span style="font-size:14px; font-weight:bold;">💍 我的婚姻</span>
<span onclick="closeMarriageStatusModal()"
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1;"></span>
</div>
{{-- 内容区(动态渲染) --}}
<div id="marriage-status-body" style="padding:16px; min-height:120px;">
<div style="text-align:center; color:#aaa; padding:30px 0; font-size:12px;">加载中…</div>
</div>
{{-- 底部操作区 --}}
<div id="marriage-status-footer" style="padding:0 16px 16px; display:flex; gap:8px;"></div>
</div>
</div>
<script>
/**
* 婚姻状态弹窗——工具栏点击「婚姻」按钮触发。
* 调用 /marriage/status 接口,展示当前用户婚姻状态(单身/求婚中/已婚)。
*/
(function() {
const CSRF = () => document.querySelector('meta[name="csrf-token"]')?.content ?? '';
const $ = id => document.getElementById(id);
/** 打开弹窗并拉取状态 */
window.openMarriageStatusModal = function() {
$('marriage-status-modal').style.display = 'flex';
$('marriage-status-body').innerHTML =
'<div style="text-align:center;color:#aaa;padding:30px 0;font-size:12px;">加载中…</div>';
$('marriage-status-footer').innerHTML = '';
fetch('/marriage/status', {
headers: {
Accept: 'application/json'
}
})
.then(r => r.json())
.then(renderMarriageStatus)
.catch(() => {
$('marriage-status-body').innerHTML =
'<div style="text-align:center;color:#e55;padding:30px 0;font-size:12px;">❌ 加载失败,请稍后重试</div>';
});
};
/** 关闭弹窗 */
window.closeMarriageStatusModal = function() {
$('marriage-status-modal').style.display = 'none';
};
// 点击遮罩关闭
$('marriage-status-modal').addEventListener('click', function(e) {
if (e.target === this) closeMarriageStatusModal();
});
/**
* 根据接口返回数据渲染弹窗内容。
*
* @param {object} data `/marriage/status` 响应 JSON
*/
function renderMarriageStatus(data) {
const body = $('marriage-status-body');
const footer = $('marriage-status-footer');
// ── 单身 ────────────────────────────────────
if (!data.status || data.status === 'none' || !data.marriage) {
body.innerHTML = `
<div style="text-align:center; padding:16px 0;">
<div style="font-size:40px; margin-bottom:10px;">🕊️</div>
<div style="font-size:14px; font-weight:bold; color:#555;">目前单身</div>
<div style="font-size:11px; color:#999; margin-top:6px; line-height:1.7;">
还没有婚姻记录。<br>可在用户名片上点击「求婚」发起求婚。
</div>
</div>`;
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1; padding:9px; background:#f3f4f6; color:#555;
border:1px solid #d1d5db; border-radius:6px; font-size:13px; cursor:pointer;">
关闭
</button>`;
return;
}
const m = data.marriage;
const isMine = (m.user && m.user.username === window.__chatUser?.username) ||
window.__chatUser?.id === m.user?.id ||
window.__chatUser?.id === m.partner?.id;
// 确定"另一方"信息(我可能是 user 也可能是 partner
const me = window.__chatUser;
const other = (m.user?.id === me?.id) ? m.partner : m.user;
const iAmUser = (m.user?.id === me?.id);
// ── 求婚中 ──────────────────────────────────
if (data.status === 'pending') {
const iProposed = iAmUser; // user_id 是发起方
const expireAt = m.expires_at ? new Date(m.expires_at).toLocaleString('zh-CN', {
hour12: false
}) : '—';
const ringHtml = m.ring ?
`<span style="font-size:13px;">${m.ring.icon ?? '💍'} ${m.ring.name}</span>` : '';
body.innerHTML = `
<div style="text-align:center; padding:8px 0;">
<div style="font-size:36px; margin-bottom:8px;">💌</div>
<div style="font-size:14px; font-weight:bold; color:#be185d;">
${iProposed ? '你向 ' + (other?.username ?? '—') + ' 发出了求婚' : (other?.username ?? '—') + ' 向你求婚啦!'}
</div>
${ringHtml ? `<div style="margin:8px 0; font-size:12px; color:#666;">戒指:${ringHtml}</div>` : ''}
<div style="font-size:11px; color:#999; margin-top:6px;">
过期时间:${expireAt}
</div>
</div>`;
if (!iProposed) {
// 被求婚方:可以接受 / 拒绝
footer.innerHTML = `
<button onclick="marriageAction('${m.id}','reject'); closeMarriageStatusModal();"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
😢 婉拒
</button>
<button onclick="marriageAction('${m.id}','accept'); closeMarriageStatusModal();"
style="flex:1;padding:9px;background:linear-gradient(135deg,#be185d,#f43f5e);
color:#fff;border:none;border-radius:6px;font-size:13px;
font-weight:bold;cursor:pointer;">
💑 答应啦!
</button>`;
} else {
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
关闭(等待对方回应)
</button>`;
}
return;
}
// ── 已婚 ────────────────────────────────────
if (data.status === 'married') {
const levelIcon = m.level_icon ?? '💑';
const levelName = m.level_name ?? '新婚';
const days = m.days ?? 0;
const intimacy = m.intimacy ?? 0;
const marriedAt = m.married_at ?? '—';
const ringHtml = m.ring ? `${m.ring.icon ?? '💍'} ${m.ring.name}` : '无';
body.innerHTML = `
<div style="text-align:center; margin-bottom:12px;">
<div style="font-size:36px; margin-bottom:6px;">${levelIcon}</div>
<div style="font-size:14px; font-weight:bold; color:#be185d;">
已与 <strong>${other?.username ?? '—'}</strong> 成婚 🎉
</div>
<div style="font-size:12px; color:#999; margin-top:4px;">婚姻等级:${levelName}</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; font-size:12px;">
<div style="background:#fdf2f8;border:1px solid #fbcfe8;border-radius:6px;padding:10px;text-align:center;">
<div style="color:#be185d;font-weight:bold;font-size:18px;">${days}</div>
<div style="color:#888;margin-top:2px;">携手天数</div>
</div>
<div style="background:#fdf4ff;border:1px solid #e9d5ff;border-radius:6px;padding:10px;text-align:center;">
<div style="color:#7c3aed;font-weight:bold;font-size:18px;">${Number(intimacy).toLocaleString()}</div>
<div style="color:#888;margin-top:2px;">亲密度</div>
</div>
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;padding:8px 10px;grid-column:1/-1;">
<span style="color:#666;">💍 戒指:</span><span style="color:#333;">${ringHtml}</span>
&nbsp;&nbsp;
<span style="color:#666;">📅 婚期:</span><span style="color:#333;">${marriedAt}</span>
</div>
</div>`;
// 已婚底部:离婚入口(需要二次确认)
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">
关闭
</button>
<button onclick="tryDivorce('${m.id}')"
style="flex:.8;padding:9px;border:1px solid #fca5a5;background:#fff;
color:#dc2626;border-radius:6px;font-size:12px;cursor:pointer;">
💔 申请离婚
</button>`;
return;
}
// 其他状态divorced 等)
body.innerHTML =
`<div style="text-align:center;color:#999;padding:30px 0;font-size:12px;">暂无有效婚姻记录</div>`;
footer.innerHTML = `
<button onclick="closeMarriageStatusModal()"
style="flex:1;padding:9px;background:#f3f4f6;color:#555;
border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer;">关闭</button>`;
}
/**
* 通用婚姻操作(接受 / 拒绝求婚)
*
* @param {string|number} marriageId marriage 记录 ID
* @param {string} action 'accept' | 'reject'
*/
window.marriageAction = async function(marriageId, action) {
try {
const res = await fetch(`/marriage/${marriageId}/${action}`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': CSRF(),
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
const data = await res.json();
if (data.ok) {
window.chatDialog?.alert(data.message || (action === 'accept' ? '已接受求婚!' : '已婉拒求婚'),
action === 'accept' ? '💑 恭喜!' : '提示', action === 'accept' ? '#be185d' :
'#6b7280');
} else {
window.chatDialog?.alert(data.message || '操作失败', '提示', '#f59e0b');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
}
};
/**
* 申请离婚(先弹确认框,再调接口)
*
* @param {string|number} marriageId marriage 记录 ID
*/
window.tryDivorce = async function(marriageId) {
closeMarriageStatusModal();
const confirmed = await window.chatDialog?.confirm(
'申请协议离婚后,对方有权同意或拒绝(拒绝即转为强制离婚,双方均扣除魅力值)。\n\n确定要申请吗',
'💔 申请离婚',
'#dc2626',
);
if (!confirmed) {
return;
}
try {
const res = await fetch(`/marriage/${marriageId}/divorce`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': CSRF(),
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
type: 'mutual'
}),
});
const data = await res.json();
window.chatDialog?.alert(data.message || '申请已发送', '提示', data.ok ? '#10b981' : '#f59e0b');
} catch {
window.chatDialog?.alert('网络异常,请稍后重试', '错误', '#ef4444');
}
};
})();
</script>
{{-- ═══════════ 银行弹窗 ═══════════ --}}
<style>
/* 银行弹窗遮罩 */
#bank-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; justify-content:center; align-items:center; }
/* 弹窗主体 */
#bank-modal-inner { width:520px; max-width:96vw; max-height:88vh; background:#fff; border-radius:8px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; }
/* 头部 */
#bank-modal-header { background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; padding:10px 16px; display:flex; align-items:center; flex-shrink:0; }
/* Tabs */
.bank-tab-btn { background:rgba(255,255,255,.15); border:none; color:#d0e8ff; border-radius:15px; padding:4px 14px; font-size:12px; cursor:pointer; font-weight:bold; transition:all .2s; }
.bank-tab-btn.active { background:#fff; color:#336699; box-shadow:0 2px 4px rgba(0,0,0,.1); }
/* 包含容器 */
.bank-view-pane { display:flex; flex-direction:column; flex:1; overflow:hidden; background:#f6faff; }
/* 余额卡片 */
.bank-balance-card { flex:1; background:#fff; border:1px solid #d0e4f5; border-radius:6px; padding:12px 14px; text-align:center; transition:box-shadow .2s; }
.bank-balance-card:hover { box-shadow:0 2px 8px rgba(51,102,153,.1); }
.bank-bcard-title { font-size:12px; margin-bottom:6px; }
.bank-bcard-val { font-size:20px; font-weight:bold; }
/* 操作区 */
.bank-op-row { display:flex; gap:10px; align-items:center; margin-bottom:10px; }
.bank-op-label { font-size:12px; font-weight:bold; width:44px; }
.bank-op-input { flex:1; height:34px; padding:0 10px; border:1px solid #b0d0ee; border-radius:4px; font-size:13px; outline:none; transition:border-color .2s; }
.bank-op-input:focus { border-color:#336699; }
.bank-op-btn { height:34px; padding:0 20px; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:13px; font-weight:bold; transition:opacity .2s; }
.bank-op-btn:hover { opacity:.85; }
.bank-op-btn:disabled { opacity:.5; cursor:not-allowed; }
/* 流水列表 */
.bank-log-item { display:flex; justify-content:space-between; align-items:center; padding:8px 0; border-bottom:1px dashed #d0e4f5; font-size:12px; }
.bank-log-item:last-child { border-bottom:none; }
.bank-log-deposit { color:#16a34a; font-weight:bold; }
.bank-log-withdraw { color:#0891b2; font-weight:bold; }
/* 排行榜单项 */
.bank-rank-item { background:#fff; border:1px solid #d0e4f5; border-radius:6px; padding:10px 14px; display:flex; align-items:center; gap:12px; font-size:13px; margin-bottom:8px; transition:border-color .2s; }
.bank-rank-item:hover { border-color:#336699; box-shadow:0 2px 8px rgba(51,102,153,.1); }
.bank-rank-num { font-size:18px; font-weight:bold; font-style:italic; width:30px; text-align:center; color:#9db3d4; }
.bank-rank-top1 { color:#fbbf24; }
.bank-rank-top2 { color:#9ca3af; }
.bank-rank-top3 { color:#b45309; }
.bank-rank-val { margin-left:auto; font-weight:bold; color:#0891b2; display:flex; align-items:center; gap:4px; }
</style>
<div id="bank-modal">
<div id="bank-modal-inner">
{{-- 标题栏 --}}
<div id="bank-modal-header">
<div style="font-size:15px; font-weight:bold; margin-right:20px;">🏦 金币银行</div>
{{-- Tabs --}}
<div style="display:flex; gap:10px; flex:1;">
<button id="bank-tabbtn-account" class="bank-tab-btn active" onclick="switchBankTab('account')">我的账户</button>
<button id="bank-tabbtn-ranking" class="bank-tab-btn" onclick="switchBankTab('ranking')">存款排行</button>
</div>
<span style="cursor:pointer; font-size:20px; opacity:.8; transition:opacity .15s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8" onclick="closeBankModal()">&times;</span>
</div>
{{-- 我的账户 面板 --}}
<div id="bank-view-account" class="bank-view-pane" style="display:flex;">
{{-- 余额展示 --}}
<div style="display:flex; gap:12px; padding:14px 16px; border-bottom:1px solid #d0e4f5; background:#fff;">
<div class="bank-balance-card" style="border-left:4px solid #3b82f6;">
<div class="bank-bcard-title" style="color:#3b82f6;">💰 流通金币</div>
<div class="bank-bcard-val" style="color:#1d4ed8;" id="bank-jjb-display"></div>
</div>
<div class="bank-balance-card" style="border-left:4px solid #0891b2;">
<div class="bank-bcard-title" style="color:#0891b2;">🏦 银行存款</div>
<div class="bank-bcard-val" style="color:#164e63;" id="bank-bank-display"></div>
</div>
</div>
{{-- 操作区 --}}
<div style="padding:14px 16px; border-bottom:1px solid #d0e4f5; background:#fff;">
<div class="bank-op-row">
<span class="bank-op-label" style="color:#3b82f6;"> </span>
<input type="number" id="bank-deposit-input" class="bank-op-input" min="1" placeholder="输入你想存入银行的金币数量...">
<button id="bank-deposit-btn" class="bank-op-btn" style="background:linear-gradient(135deg,#3b82f6,#60a5fa);" onclick="bankAction('deposit')">确认存入</button>
</div>
<div class="bank-op-row" style="margin-bottom:0;">
<span class="bank-op-label" style="color:#0891b2;"> </span>
<input type="number" id="bank-withdraw-input" class="bank-op-input" min="1" placeholder="输入你想从银行取回的金币数量...">
<button id="bank-withdraw-btn" class="bank-op-btn" style="background:linear-gradient(135deg,#0891b2,#22d3ee);" onclick="bankAction('withdraw')">确认取出</button>
</div>
<div id="bank-op-msg" style="display:none; text-align:center; font-size:12px; font-weight:bold; padding:8px 10px; border-radius:6px; margin-top:12px;"></div>
</div>
{{-- 流水记录 --}}
<div style="padding:8px 16px; font-size:12px; font-weight:bold; color:#336699; background:#fff; border-bottom:1px solid #e0ecf8;">
📝 最近资金流水
</div>
<div id="bank-logs-list" style="flex:1; overflow-y:auto; padding:10px 16px;">
<div style="text-align:center; color:#9db3d4; font-size:12px; padding:30px 0;">加载中...</div>
</div>
</div>
{{-- 存款排行 面板 --}}
<div id="bank-view-ranking" class="bank-view-pane" style="display:none;">
<div style="padding:10px 16px; border-bottom:1px solid #d0e4f5; display:flex; justify-content:space-between; align-items:center; background:#fff;">
<div style="font-size:13px; color:#336699; font-weight:bold;">🏆 全站存款排行榜</div>
<button onclick="toggleBankRankSort()" id="bank-rank-sort-btn" style="background:#f0f6ff; border:1px solid #b0d0ee; color:#336699; padding:4px 12px; border-radius:15px; font-size:12px; cursor:pointer; font-weight:bold; transition:background .2s;">
降序
</button>
</div>
<div id="bank-ranking-list" style="flex:1; overflow-y:auto; padding:12px 16px;">
<div style="text-align:center; color:#9db3d4; font-size:12px; padding:30px 0;">加载中...</div>
</div>
{{-- 分页控件 --}}
<div style="padding:10px 16px; border-top:1px solid #d0e4f5; background:#fff; display:flex; justify-content:space-between; align-items:center;">
<button class="bank-tab-btn" id="bank-rank-prev" onclick="fetchBankRanking(bankRankPage - 1)" style="color:#336699; border:1px solid #b0d0ee; background:#fff; font-weight:normal;">上一页</button>
<div id="bank-rank-page-info" style="font-size:12px; color:#666; font-weight:bold;">1 / 1</div>
<button class="bank-tab-btn" id="bank-rank-next" onclick="fetchBankRanking(bankRankPage + 1)" style="color:#336699; border:1px solid #b0d0ee; background:#fff; font-weight:normal;">下一页</button>
</div>
</div>
</div>
</div>
<script>
let bankRankPage = 1;
let bankRankSort = 'desc';
/** 切换 Tab */
function switchBankTab(tabName) {
document.getElementById('bank-tabbtn-account').classList.remove('active');
document.getElementById('bank-tabbtn-ranking').classList.remove('active');
document.getElementById('bank-view-account').style.display = 'none';
document.getElementById('bank-view-ranking').style.display = 'none';
document.getElementById('bank-tabbtn-' + tabName).classList.add('active');
document.getElementById('bank-view-' + tabName).style.display = 'flex';
if (tabName === 'ranking' && bankRankPage === 1 && document.getElementById('bank-ranking-list').innerHTML.includes('加载中')) {
fetchBankRanking(1);
}
}
/** 打开弹窗 */
function openBankModal() {
document.getElementById('bank-modal').style.display = 'flex';
switchBankTab('account');
bankLoadInfo();
}
/** 关闭弹窗 */
function closeBankModal() {
document.getElementById('bank-modal').style.display = 'none';
}
/** 存取款合并 */
async function bankAction(type) {
const inputEl = document.getElementById(`bank-${type}-input`);
const btnEl = document.getElementById(`bank-${type}-btn`);
const amount = parseInt(inputEl.value, 10);
if (!amount || amount <= 0) {
bankShowMsg('请输入有效的金额', false);
return;
}
btnEl.disabled = true;
try {
const res = await fetch(`/bank/${type}`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ amount }),
});
const data = await res.json();
bankShowMsg(data.message, data.status === 'success');
if (data.status === 'success') {
inputEl.value = '';
bankLoadInfo();
if (document.getElementById('bank-view-ranking').style.display !== 'none') {
fetchBankRanking(bankRankPage);
}
}
} catch (e) {
bankShowMsg('网络异常,请稍后重试', false);
}
btnEl.disabled = false;
}
/** 获取资金与流水 */
async function bankLoadInfo() {
try {
const res = await fetch('/bank', { headers: { 'Accept': 'application/json' } });
const data = await res.json();
if (data.status !== 'success') return;
document.getElementById('bank-jjb-display').textContent = data.jjb.toLocaleString();
document.getElementById('bank-bank-display').textContent = data.bank_jjb.toLocaleString();
if (window.chatContext) {
window.chatContext.myGold = data.jjb;
window.chatContext.bankGold = data.bank_jjb;
}
const list = document.getElementById('bank-logs-list');
if (data.logs.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9db3d4;font-size:12px;padding:30px 0;">暂无记录</div>';
return;
}
list.innerHTML = data.logs.map(log => {
const isDeposit = log.type === 'deposit';
const cls = isDeposit ? 'bank-log-deposit' : 'bank-log-withdraw';
const sign = isDeposit ? '+ 存入' : '- 取出';
const dt = log.created_at.replace('T', ' ').substring(0, 16);
return `<div class="bank-log-item">
<span class="${cls}">${sign} ${log.amount.toLocaleString()} 金币</span>
<span style="color:#64748b;">余额: <strong style="color:#334155">${log.balance_after.toLocaleString()}</strong></span>
<span style="color:#94a3b8;">${dt}</span>
</div>`;
}).join('');
} catch (e) {
document.getElementById('bank-logs-list').innerHTML = '<div style="text-align:center;color:#ef4444;font-size:12px;padding:30px 0;">加载失败</div>';
}
}
function bankShowMsg(msg, success) {
const el = document.getElementById('bank-op-msg');
el.textContent = msg;
el.style.background = success ? '#f0fdf4' : '#fef2f2';
el.style.border = success ? '1px solid #bbf7d0' : '1px solid #fecaca';
el.style.color = success ? '#16a34a' : '#ef4444';
el.style.display = 'block';
clearTimeout(el._t);
el._t = setTimeout(() => { el.style.display = 'none'; }, 3000);
}
function toggleBankRankSort() {
bankRankSort = bankRankSort === 'desc' ? 'asc' : 'desc';
document.getElementById('bank-rank-sort-btn').textContent = bankRankSort === 'desc' ? '降序 ↓' : '升序 ↑';
fetchBankRanking(1);
}
async function fetchBankRanking(page) {
if (page < 1) return;
const listEl = document.getElementById('bank-ranking-list');
listEl.innerHTML = '<div style="text-align:center; color:#9db3d4; font-size:12px; padding:30px 0;">加载中...</div>';
try {
const res = await fetch(`/bank/ranking?page=${page}&sort=${bankRankSort}`, { headers: {'Accept': 'application/json'} });
const data = await res.json();
if (data.status === 'success') {
bankRankPage = data.pagination.current_page;
document.getElementById('bank-rank-page-info').textContent = `${bankRankPage} / ${data.pagination.last_page}`;
const btnPrev = document.getElementById('bank-rank-prev');
const btnNext = document.getElementById('bank-rank-next');
btnPrev.disabled = bankRankPage <= 1;
btnPrev.style.opacity = bankRankPage <= 1 ? 0.5 : 1;
btnNext.disabled = bankRankPage >= data.pagination.last_page;
btnNext.style.opacity = bankRankPage >= data.pagination.last_page ? 0.5 : 1;
if (data.ranking.length === 0) {
listEl.innerHTML = '<div style="text-align:center; color:#9db3d4; font-size:12px; padding:30px 0;">暂无数据</div>';
return;
}
let html = '';
data.ranking.forEach((u, index) => {
const absNum = (bankRankPage - 1) * 20 + index + 1;
let rankCls = '';
if (bankRankSort === 'desc' && bankRankPage === 1) {
if (absNum === 1) rankCls = 'bank-rank-top1';
else if (absNum === 2) rankCls = 'bank-rank-top2';
else if (absNum === 3) rankCls = 'bank-rank-top3';
}
html += `
<div class="bank-rank-item">
<div class="bank-rank-num ${rankCls}">${absNum}</div>
<img src="${u.headfaceUrl || ''}" onerror="this.src='/images/headface/1.gif'" style="width:32px; height:32px; border-radius:50%; object-fit:cover; border:1px solid #d0e4f5;">
<div>
<div style="font-weight:bold; color:#1e3a8a;">${u.username} <span style="font-size:12px;">${u.sex === '女' ? '♀' : '♂'}</span></div>
</div>
<div class="bank-rank-val">
🏦 ${Number(u.bank_jjb).toLocaleString()}
</div>
</div>
`;
});
listEl.innerHTML = html;
}
} catch (e) {
listEl.innerHTML = '<div style="text-align:center; color:#ef4444; font-size:12px; padding:30px 0;">拉取失败</div>';
}
}
document.getElementById('bank-modal').addEventListener('click', function(e) {
if (e.target === this) closeBankModal();
});
</script>