功能:字体颜色持久化、等级体系升级至99级、钓鱼小游戏、补充系统参数

- 字体颜色:s_color 改为 varchar,发消息时保存颜色,进入聊天室自动恢复
- 等级体系:maxlevel 15→99,superlevel 16→100,99级经验阶梯(幂次曲线)
- 管理权限等级按比例调整:禁言50、踢人60、设公告60、封号80、封IP90
- 钓鱼小游戏:FishingController(抛竿扣金币+收竿随机结果+广播)
- 补充6个缺失的 sysparam 参数 + 4个钓鱼参数
- 用户列表点击用户名后自动聚焦输入框
- Pint 格式化
This commit is contained in:
2026-02-26 21:10:34 +08:00
parent d884853968
commit ea06328885
652 changed files with 5013 additions and 1274 deletions
+151 -426
View File
@@ -1,3 +1,12 @@
{{--
文件功能:聊天室主界面框架(frame 页面)
全屏沉浸式布局,不使用统一 layout
CSS 抽取到 /public/css/chat.css
JS 抽取到 chat.partials.scripts Blade 模板
@author ChatRoom Laravel
@version 1.0.0
--}}
<!DOCTYPE html>
<html lang="zh-CN">
@@ -5,139 +14,84 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $room->name ?? '聊天室' }} - 飘落流星</title>
<!-- 引入全局 CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<!-- 传递必要的上下文数据给 chat.js 模块使用 -->
@php
// 从 sysparam 读取权限等级配置
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '60');
$levelMute = (int) \App\Models\Sysparam::getValue('level_mute', '50');
$levelBan = (int) \App\Models\Sysparam::getValue('level_ban', '80');
$levelBanip = (int) \App\Models\Sysparam::getValue('level_banip', '90');
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
@endphp
<script>
window.chatContext = {
roomId: {{ $room->id }},
username: "{{ $user->username }}",
userLevel: {{ $user->user_level }},
superLevel: {{ $superLevel }},
levelKick: {{ $levelKick }},
levelMute: {{ $levelMute }},
levelBan: {{ $levelBan }},
levelBanip: {{ $levelBanip }},
sendUrl: "{{ route('chat.send', $room->id) }}",
leaveUrl: "{{ route('chat.leave', $room->id) }}"
leaveUrl: "{{ route('chat.leave', $room->id) }}",
heartbeatUrl: "{{ route('chat.heartbeat', $room->id) }}",
fishCastUrl: "{{ route('fishing.cast', $room->id) }}",
fishReelUrl: "{{ route('fishing.reel', $room->id) }}"
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
<style>
/* 自定义滚动条样式,让界面更清爽 */
.chat-scroll::-webkit-scrollbar {
width: 6px;
}
.chat-scroll::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 4px;
}
.chat-scroll::-webkit-scrollbar-track {
background-color: transparent;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="/css/chat.css">
</head>
<body class="bg-gray-100 flex h-screen overflow-hidden text-sm">
<body>
<div class="chat-layout">
<!-- 左侧/中间部分:主聊天区域与控制台 -->
<div class="flex-1 flex flex-col h-full bg-white relative">
<!-- 头部房间信息栏 -->
<header
class="h-14 border-b bg-gradient-to-r from-blue-500 to-indigo-600 text-white flex items-center justify-between px-6 shadow-sm z-10">
<div class="flex items-center space-x-3">
<span class="font-bold text-lg tracking-wide">{{ $room->name }}</span>
<span id="room-title-display"
class="text-xs bg-white/20 px-2 py-1 rounded-full">{{ $room->description ?? '欢迎光临!' }}</span>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm opacity-90"><i class="font-semibold">{{ $user->username }}</i>
(LV.{{ $user->user_level }})</span>
<button type="button" onclick="leaveRoom()"
class="px-3 py-1.5 bg-white/10 hover:bg-red-500 hover:text-white rounded transition text-xs font-semibold">
退出房间
</button>
</div>
</header>
{{-- ═══════════ 左侧主区域 ═══════════ --}}
<div class="chat-left">
<!-- 聊天记录展示区 -->
<main id="chat-messages-container" class="flex-1 overflow-y-auto p-4 chat-scroll bg-gray-50/50 space-y-3">
<div class="text-center text-xs text-gray-400 my-4">-- 以上是历史消息 --</div>
<!-- 气泡动态挂载点 -->
</main>
{{-- 顶部标题栏 + 公告滚动条(独立文件维护) --}}
@include('chat.partials.header')
<!-- 底部发言控制区 -->
<footer class="h-auto min-h-24 bg-white border-t p-3 shadow-inner z-10">
<form id="chat-form" onsubmit="sendMessage(event)" class="max-w-5xl mx-auto flex flex-col space-y-2">
<div class="flex items-center space-x-3 text-xs text-gray-600 px-1">
<label class="flex items-center space-x-1 cursor-pointer">
<span></span>
<select id="to_user" name="to_user"
class="border border-gray-300 rounded px-1 py-0.5 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<option value="大家" selected>所有人</option>
<!-- 在线人员将动态加载到这里 -->
</select>
<span>:</span>
</label>
<label class="flex items-center space-x-1 cursor-pointer">
<span>动作:</span>
<select id="action" name="action"
class="border border-gray-300 rounded px-1 py-0.5 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<option value="">(无动作)</option>
<option value="微笑">微笑</option>
<option value="大笑">大笑</option>
<option value="愤怒">愤怒</option>
</select>
</label>
<label class="flex items-center space-x-1 cursor-pointer">
<span>字体色:</span>
<input type="color" id="font_color" name="font_color" value="#000000"
class="w-6 h-6 p-0 border-0 cursor-pointer rounded">
</label>
<label class="flex items-center space-x-1 cursor-pointer hover:text-blue-600 transition">
<input type="checkbox" id="is_secret" name="is_secret" value="1"
class="text-blue-500 rounded focus:ring-blue-500">
<span>悄悄话</span>
</label>
{{-- 消息窗格(双窗格,默认只显示 say1 --}}
<div class="message-panes" id="message-panes">
{{-- 主消息窗 --}}
<div class="message-pane say1" id="chat-messages-container">
<div class="msg-line">
<span style="color: #cc0000; font-weight: bold;">【公众窗口】</span>显示公众的发言!
<span class="msg-time">({{ now()->format('H:i:s') }})</span><br>
<span
style="color: #000099;">{{ $room->name }}{{ $room->description ?? '欢迎光临!畅所欲言,文明聊天。' }}</span>
</div>
</div>
<div class="flex items-end space-x-2">
<textarea id="content" name="content" rows="2"
class="flex-1 border border-gray-300 rounded-md p-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none transition-shadow text-sm"
placeholder="在这里输入聊天内容,按 Ctrl+Enter 快捷发送..."></textarea>
<button type="submit" id="send-btn"
class="h-11 px-6 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md shadow transition flex-shrink-0">
发送消息
</button>
{{-- 副消息窗(包厢窗) --}}
<div class="message-pane say2" id="chat-messages-container2">
<div class="msg-line">
<span style="color: #cc0000; font-weight: bold;">【包厢窗口】</span>显示包厢名单中聊友的发言!
<span class="msg-time">({{ now()->format('H:i:s') }})</span>
</div>
</div>
</form>
</footer>
</div>
{{-- 底部输入工具栏(独立文件维护) --}}
@include('chat.partials.input-bar')
</div>
{{-- ═══════════ 竖向工具条(独立文件维护) ═══════════ --}}
@include('chat.partials.toolbar')
{{-- ═══════════ 右侧用户面板(独立文件维护) ═══════════ --}}
@include('chat.partials.right-panel')
</div>
<!-- 右侧:在线人员面板 -->
<aside class="w-64 bg-gray-50 border-l flex flex-col h-full shadow-lg z-20">
<div class="h-14 border-b bg-gray-100 flex items-center justify-center font-bold text-gray-700 tracking-wider">
📶 在线人员 (<span id="online-count">0</span>)
</div>
<div class="flex-1 overflow-y-auto p-2 chat-scroll">
<ul id="online-users-list" class="space-y-1">
<!-- 在线列表渲染点 -->
<li class="flex items-center justify-center h-full text-xs text-gray-400 mt-10">加载中...</li>
</ul>
</div>
</aside>
<!-- 弹窗容器 (Alpine 作用域外置挂载) -->
{{-- ═══════════ 用户名片弹窗 (Alpine.js) ═══════════ --}}
<div id="user-modal-container" x-data="{
showUserModal: false,
userInfo: {},
isMuting: false,
muteDuration: 5,
// 获取用户资料
async fetchUser(username) {
try {
const res = await fetch('/user/' + encodeURIComponent(username));
@@ -149,7 +103,6 @@
}
},
// 执行踢出
async kickUser() {
if (!confirm('确定要将 ' + this.userInfo.username + ' 踢出房间吗?')) return;
try {
@@ -173,7 +126,6 @@
}
},
// 执行禁言
async muteUser() {
try {
const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/mute', {
@@ -200,337 +152,110 @@
}
}
}">
<!-- 用户名片弹窗 -->
<div x-show="showUserModal" style="display: none;"
class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showUserModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden transform transition-all"
x-transition.scale.95>
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 h-24 relative">
<button @click="showUserModal = false"
class="absolute top-3 right-3 text-white/80 hover:text-white font-bold">&times;</button>
<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="px-6 pb-6 relative pt-12">
<!-- 头像 (暂时用占位圆圈代替,后续从 /images/headface 读取) -->
<div
class="absolute -top-12 left-6 w-20 h-20 rounded-full border-4 border-white bg-gray-200 flex items-center justify-center text-gray-400 font-bold text-xl shadow-md">
<img x-show="userInfo.headface" :src="'/images/headface/' + userInfo.headface"
class="w-full h-full rounded-full object-cover"
@@error="$el.style.display='none'">
<span x-show="!userInfo.headface">Pic</span>
</div>
<div class="mt-2">
<h3 class="text-2xl font-bold text-gray-800 flex items-center space-x-2">
<span x-text="userInfo.username"></span>
<span
:class="userInfo.sex === '男' ? 'bg-blue-100 text-blue-700' : (userInfo
.sex === '女' ? 'bg-pink-100 text-pink-700' : 'bg-gray-100 text-gray-700')"
class="text-[10px] px-2 py-0.5 rounded-full" x-text="userInfo.sex"></span>
</h3>
<p class="text-indigo-600 text-sm font-semibold mt-1">LV.<span
x-text="userInfo.user_level"></span></p>
<div class="mt-4 bg-gray-50 border border-gray-100 rounded-lg p-3">
<p class="text-sm text-gray-600 italic" x-text="userInfo.sign"></p>
</div>
<p class="text-xs text-gray-400 mt-3 border-t pt-2">加入时间: <span
x-text="userInfo.created_at"></span></p>
</div>
<!-- 特权操作区(仅超管或房主显示踢人操作)-->
@if (Auth::user()->user_level >= 15 || $room->master == Auth::user()->username)
<div class="mt-6 pt-4 border-t border-gray-100"
x-show="userInfo.username !== window.chatContext.username && userInfo.user_level < {{ Auth::user()->user_level }}">
<p class="text-xs font-bold text-red-400 mb-2">特权操作</p>
<div class="flex space-x-2">
<button @click="kickUser()"
class="flex-1 bg-red-100 text-red-700 hover:bg-red-200 py-1.5 rounded-md text-sm font-bold transition">踢出房间</button>
<button @click="isMuting = !isMuting"
class="flex-1 bg-amber-100 text-amber-700 hover:bg-amber-200 py-1.5 rounded-md text-sm font-bold transition">禁言拦截</button>
</div>
<!-- 禁言表单 -->
<div x-show="isMuting"
class="mt-3 bg-amber-50 rounded p-2 flex items-center space-x-2 border border-amber-200"
style="display: none;">
<input type="number" x-model="muteDuration"
class="w-full border-amber-300 rounded focus:ring-amber-500 text-sm px-2 py-1"
placeholder="分钟" min="1">
<span class="text-xs text-amber-800 shrink-0">分钟</span>
<button @click="muteUser()"
class="bg-amber-500 hover:bg-amber-600 text-white px-3 py-1 rounded text-sm font-bold shrink-0 shadow-sm">执行</button>
{{-- 弹窗内容 --}}
<div class="modal-body">
<div class="profile-row">
<img class="profile-avatar" x-show="userInfo.headface"
:src="'/images/headface/' + userInfo.headface" 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 style="font-size: 11px; color: #999; margin-top: 2px;">
加入: <span x-text="userInfo.created_at"></span>
</div>
</div>
@endif
</div>
<div class="profile-detail" x-text="userInfo.sign || '这家伙很懒,什么也没留下'"></div>
</div>
<!-- 常规操作:飞鸽传书 私信 -->
<div class="px-6 pb-6 pt-2" x-show="userInfo.username !== window.chatContext.username">
<a :href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank"
class="w-full bg-pink-100 text-pink-700 hover:bg-pink-200 py-2.5 rounded-lg font-bold transition flex items-center justify-center shadow-sm text-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
飞鸽传书 (发私信)
{{-- 操作按钮 --}}
<div class="modal-actions" x-show="userInfo.username !== window.chatContext.username">
<button class="btn-whisper"
x-on:click="document.getElementById('to_user').value = userInfo.username; document.getElementById('content').focus(); showUserModal = false;">
悄悄话
</button>
<a class="btn-mail"
:href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank">
写私信
</a>
</div>
{{-- 特权操作(管理员/房主) --}}
@if (Auth::user()->user_level >= $levelKick || $room->master == Auth::user()->username)
<div style="padding: 0 16px 12px;"
x-show="userInfo.username !== window.chatContext.username && userInfo.user_level < {{ Auth::user()->user_level }}">
<div style="font-size: 11px; color: #c00; margin-bottom: 6px; font-weight: bold;">管理操作</div>
<div style="display: flex; gap: 8px;">
<button class="btn-kick" style="flex:1; padding: 5px; border-radius: 4px;"
x-on:click="kickUser()">踢出</button>
<button class="btn-mute" style="flex:1; padding: 5px; border-radius: 4px;"
x-on:click="isMuting = !isMuting">禁言</button>
</div>
</div>
<div class="mute-form" x-show="isMuting" style="display: none;">
<input type="number" x-model="muteDuration" min="1" placeholder="分钟">
<span style="font-size: 11px; color: #b86e00;">分钟</span>
<button x-on:click="muteUser()">执行</button>
</div>
@endif
</div>
</div>
</div>
<script>
// 核心页面交互逻辑,连接 chat.js 抛出的自定义事件
{{-- ═══════════ 聊天室交互脚本(独立文件维护) ═══════════ --}}
@include('chat.partials.scripts')
const container = document.getElementById('chat-messages-container');
const userList = document.getElementById('online-users-list');
const toUserSelect = document.getElementById('to_user');
const onlineCount = document.getElementById('online-count');
{{-- ═══════════ 头像选择弹窗 ═══════════ --}}
<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>
let onlineUsers = {}; // 用于本地维护在线名单
{{-- 预览区 --}}
<div
style="padding:10px 16px; background:#f0f6ff; border-bottom:1px solid #ddd; display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:#666;">当前选中:</span>
<img id="avatar-preview" src="/images/headface/{{ $user->usersf ?: '1.GIF' }}"
style="width:40px; height:40px; border:2px solid #336699; border-radius:4px;">
<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>
// 辅助:滚动到底部
function scrollToBottom() {
container.scrollTop = container.scrollHeight;
}
{{-- 头像网格 --}}
<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>
// 辅助:渲染在线人员列表
function renderUserList() {
userList.innerHTML = '';
// 同时更新“对谁说”下拉框(保留大家选项)
toUserSelect.innerHTML = '<option value="大家">所有人</option>';
let count = 0;
for (let username in onlineUsers) {
count++;
let user = onlineUsers[username];
// 渲染右侧面板
let li = document.createElement('li');
li.className =
'px-3 py-2 hover:bg-blue-50 rounded cursor-pointer transition flex items-center justify-between border-b border-gray-100 last:border-0';
li.innerHTML = `
<div class="flex items-center space-x-2 truncate">
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0 shadow-[0_0_5px_rgba(34,197,94,0.5)]"></span>
<span class="text-sm font-medium text-gray-700 truncate" title="${username}">${username}</span>
</div>
`;
// 单击右侧列表可以快速查看资料 / @ 人
li.onclick = () => {
toUserSelect.value = username;
// 触发 Alpine 挂载的查看名片方法
const modalScope = document.querySelector('[x-data]').__x.$data;
if (modalScope && username !== window.chatContext.username) {
modalScope.fetchUser(username);
}
};
userList.appendChild(li);
// 添加到“对谁说”列表
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
option.textContent = username;
toUserSelect.appendChild(option);
}
}
onlineCount.innerText = count;
}
// 辅助:渲染单条消息气泡
function appendMessage(msg) {
const isMe = msg.from_user === window.chatContext.username;
const alignClass = isMe ? 'justify-end' : 'justify-start';
const bubbleBg = isMe ? 'bg-blue-500 text-white' : 'bg-white border border-gray-200 text-gray-800';
const textColorAttr = msg.font_color && msg.font_color !== '#000000' && msg.font_color !== '#000' && !isMe ?
`color: ${msg.font_color}` : '';
let headerText = '';
// 辅助:生成可点击的用户名 HTML
const clickableUser = (uName) =>
`<span class="cursor-pointer hover:underline hover:text-blue-600 transition" onclick="document.querySelector('[x-data]').__x.$data.fetchUser('${uName}')">${uName}</span>`;
if (msg.to_user !== '大家') {
headerText = `${clickableUser(msg.from_user)} 对 ${clickableUser(msg.to_user)} ${msg.action} 说:`;
if (msg.is_secret) headerText = `[悄悄话] ` + headerText;
} else {
headerText = `${clickableUser(msg.from_user)} ${msg.action} 说:`;
}
const div = document.createElement('div');
div.className = `flex ${alignClass} mb-3 group`;
let html = `
<div class="max-w-[75%] flex flex-col space-y-1">
<div class="text-[11px] text-gray-400 ${isMe ? 'text-right hidden group-hover:block transition-all' : 'text-left pl-1'}">${headerText} <span class="ml-2 font-mono">${msg.sent_at}</span></div>
<div class="px-4 py-2 rounded-2xl shadow-sm leading-relaxed whitespace-pre-wrap word-break ${bubbleBg}" style="${textColorAttr}">${msg.content}</div>
</div>
`;
div.innerHTML = html;
container.appendChild(div);
scrollToBottom();
}
// 🚀 初始化 WebSocket 监听器
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
}
});
// 🔌 监听 WebSocket 事件
window.addEventListener('chat:here', (e) => {
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
});
renderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
renderUserList();
// 可选:渲染一条系统提示“某某加入了房间”
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
renderUserList();
});
window.addEventListener('chat:message', (e) => {
const msg = e.detail;
// 过滤私聊:如果是别人对别人的悄悄话,自己不应该显示
if (msg.is_secret && msg.from_user !== window.chatContext.username && msg.to_user !== window.chatContext
.username) {
return;
}
appendMessage(msg);
});
window.addEventListener('chat:kicked', (e) => {
if (e.detail.username === window.chatContext.username) {
alert("您已被管理员踢出房间!");
window.location.href = "{{ route('home') }}";
}
});
window.addEventListener('chat:title-updated', (e) => {
document.getElementById('room-title-display').innerText = e.detail.title;
});
// 📤 发送消息逻辑
document.getElementById('content').addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
sendMessage(e);
}
});
async function sendMessage(e) {
if (e) e.preventDefault();
const form = document.getElementById('chat-form');
const formData = new FormData(form);
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const content = formData.get('content').trim();
if (!content) {
contentInput.focus();
return;
}
// 锁定按钮防连点
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
try {
const response = await fetch(window.chatContext.sendUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
},
body: formData
});
const data = await response.json();
if (response.ok && data.status === 'success') {
// 发送成功,清空刚才的输入并获取焦点
contentInput.value = '';
contentInput.focus();
} else {
alert('发送失败: ' + (data.message || JSON.stringify(data.errors)));
}
} catch (error) {
alert('网络连接错误,消息发送失败!');
console.error(error);
} finally {
// 解锁按钮
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
// 🚪 退出房间逻辑
async function leaveRoom() {
if (!confirm('确定要离开聊天室吗?')) return;
try {
await fetch(window.chatContext.leaveUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
} catch (e) {
console.error(e);
}
window.location.href = "{{ route('home') }}";
}
// ⏳ 自动挂机心跳 (每 3 分钟执行一次)
const HEARTBEAT_INTERVAL = 180 * 1000;
setInterval(async () => {
if (!window.chatContext || !window.chatContext.heartbeatUrl) return;
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.status === 'success') {
// 可选:在这里如果需要更新自己的名片经验条,可触发 Alpine 等级更新(如果实现了前台独立显示自己经验的功能的话)
console.log('心跳存点成功,当前经验值:' + data.data.exp_num + ', 等级:' + data.data.user_level);
}
} catch (e) {
console.error('挂机心跳断开', e);
}
}, HEARTBEAT_INTERVAL);
</script>
</body>
</html>
@@ -0,0 +1,22 @@
{{--
文件功能:聊天室顶部标题栏 + 公告滚动条
frame.blade.php 拆分,便于独立维护
依赖变量:$room
--}}
{{-- 顶部标题栏(第一行:房间名称 + 介绍) --}}
<div class="room-title-bar">
<div class="room-name">{{ $room->name }}<span style="font-size:12px; font-weight:normal;">在线<span
id="online-count">0</span></span></div>
<div class="room-desc" id="room-title-display">{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }}
</div>
</div>
{{-- 第二行:公告/祝福语滚动条(有权限的用户可设置) --}}
<div class="room-announcement-bar" id="announcement-bar">
<marquee behavior="scroll" direction="left" scrollamount="3" style="color:#cc0000; font-size:12px; font-weight:bold;"
id="announcement-text">
{{ $room->announcement ?? '欢迎来到聊天室!祝大家聊天愉快!' }}
</marquee>
</div>
@@ -0,0 +1,102 @@
{{--
文件功能:聊天室底部输入工具栏(两行结构)
第一行:发送对象、动作、字色、悄悄话、滚屏、分屏、管理操作
第二行:输入框 + 发送按钮
frame.blade.php 拆分,便于独立维护
依赖变量:$user, $room, $levelKick, $levelMute, $levelBan, $levelBanip
--}}
<div class="input-bar">
<form id="chat-form" onsubmit="sendMessage(event)">
{{-- 第一行:工具选项 --}}
<div class="input-row">
<label>
<select id="to_user" name="to_user" style="color: #224466;">
<option value="大家" selected>大家</option>
</select>
:
</label>
<label>动作:
<select id="action" name="action">
<option value=""></option>
<option value="微笑">微笑</option>
<option value="大笑">大笑</option>
<option value="愤怒">愤怒</option>
<option value="哭泣">哭泣</option>
<option value="害羞">害羞</option>
<option value="鄙视">鄙视</option>
<option value="得意">得意</option>
<option value="疑惑">疑惑</option>
<option value="同情">同情</option>
<option value="无奈">无奈</option>
<option value="拳打">拳打</option>
<option value="飞吻">飞吻</option>
<option value="偷看">偷看</option>
</select>
</label>
<label>字色:
<input type="color" id="font_color" name="font_color" value="{{ $user->s_color ?? '#000000' }}"
style="width: 22px; height: 18px; padding: 0; border: 1px solid navy; cursor: pointer;">
</label>
<label title="仅对方和自己可见">
<input type="checkbox" id="is_secret" name="is_secret" value="1">
悄悄话
</label>
<label title="自动滚屏到最新消息">
<input type="checkbox" id="auto_scroll" checked>
滚屏
</label>
<label>分屏:
<select id="split_screen" onchange="changeSplitScreen(this.value)">
<option value="0">单窗</option>
<option value="1">上下分</option>
</select>
</label>
{{-- 管理操作(根据权限显示) --}}
@if ($user->user_level >= $levelKick || $room->master == $user->username)
<select id="admin-action-select"
style="font-size: 11px; padding: 1px 2px; border: 1px solid #8ab; border-radius: 2px; color: #c00;">
<option value="">管理操作 </option>
@if ($user->user_level >= $levelKick || $room->master == $user->username)
<option value="kick">踢出房间</option>
@endif
@if ($user->user_level >= $levelMute || $room->master == $user->username)
<option value="mute">禁言</option>
@endif
@if ($user->user_level >= $levelBan)
<option value="ban">封号</option>
@endif
@if ($user->user_level >= $levelBanip)
<option value="banip">封IP</option>
@endif
</select>
<button type="button" onclick="executeAdminAction()"
style="font-size: 11px; padding: 1px 6px; background: #c44; color: #fff; border: none; border-radius: 2px; cursor: pointer;">执行</button>
@endif
@if (
$user->user_level >= (int) \App\Models\Sysparam::getValue('level_announcement', '10') ||
$room->master == $user->username)
<button type="button" onclick="promptAnnouncement()"
style="font-size: 11px; padding: 1px 6px; background: #4a9; color: #fff; border: none; border-radius: 2px; cursor: pointer;">设公告</button>
@endif
<button type="button" id="fishing-btn" onclick="startFishing()"
style="font-size: 11px; padding: 1px 6px; background: #2563eb; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🎣
钓鱼</button>
</div>
{{-- 第二行:输入框 + 发送 --}}
<div class="input-row">
<input type="text" id="content" name="content" class="say-input" placeholder="在这里输入聊天内容,按 Enter 发送..."
autocomplete="off">
<button type="submit" id="send-btn" class="send-btn">发送</button>
</div>
</form>
</div>
@@ -0,0 +1,91 @@
{{--
文件功能:右侧用户面板(复刻原版)
包含:名单/房间/贴图/酷库 四个 Tab 面板
独立文件方便维护
依赖变量:$room(当前房间模型)
--}}
<div class="chat-right">
{{-- Tab 标题栏(原版:名单/房间/贴图/酷库) --}}
<div class="right-tabs">
<button class="tab-btn active" id="tab-users" onclick="switchTab('users')">名单</button>
<button class="tab-btn" id="tab-rooms" onclick="switchTab('rooms')">房间</button>
<button class="tab-btn" id="tab-emoji" onclick="switchTab('emoji')">贴图</button>
<button class="tab-btn" id="tab-action" onclick="switchTab('action')">酷库</button>
</div>
{{-- 用户列表面板 --}}
<div class="user-list-content" id="panel-users">
{{-- 房间信息头部(原版风格:房间名 + 人气 + 在线人数) --}}
<div style="text-align:center; padding:4px 6px; border-bottom:1px solid #cde; background:#f0f6ff;">
<div style="color:#336699; font-weight:bold; font-size:12px;">{{ $room->name }}</div>
<div style="color:#cc6600; font-size:11px;">人气:{{ $room->visit_num ?? 0 }}</div>
<div style="font-size:11px; color:#999; margin-top:2px;">
<span id="online-count-bottom">0</span>
<a href="#" onclick="renderUserList(); return false;"
style="color:#c00; font-size:10px; margin-left:4px;">刷新</a>
</div>
</div>
{{-- 排序 + 搜索(原版风格) --}}
<div style="padding:3px 4px; border-bottom:1px solid #cde; background:#f8fbff; font-size:11px;">
<div style="display:flex; align-items:center; gap:4px; margin-bottom:3px;">
<span style="color:#666; flex-shrink:0;">排序:</span>
<select id="user-sort-select" onchange="renderUserList()"
style="flex:1; font-size:11px; padding:1px; border:1px solid #aac; border-radius:2px;">
<option value="default">默认</option>
<option value="name">按名称</option>
<option value="level">按等级</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:2px;">
<input type="text" id="user-search-input" placeholder="搜索用户" onkeyup="filterUserList()"
style="width:100%; font-size:11px; padding:2px 4px; border:1px solid #aac; border-radius:2px; box-sizing:border-box;">
</div>
</div>
{{-- 用户列表 --}}
<div id="online-users-list" style="padding:2px;">
<div style="text-align:center; color:#999; padding:20px 0; font-size:11px;">加载中...</div>
</div>
{{-- 管理员名单链接 --}}
<div style="text-align:center; padding:4px; border-top:1px solid #cde; font-size:11px;">
<a href="#" style="color:#336699; font-weight:bold;">管理员名单</a>
</div>
</div>
{{-- 房间列表面板 --}}
<div class="user-list-content" id="panel-rooms" style="display: none;">
<div style="text-align: center; color: #999; padding: 20px 0; font-size: 11px;">
<a href="{{ route('rooms.index') }}" target="_blank">打开大厅查看房间列表</a>
</div>
</div>
{{-- 贴图面板 --}}
<div class="user-list-content" id="panel-emoji" style="display: none; padding: 6px;">
<div style="display: flex; flex-wrap: wrap; gap: 2px; justify-content: center;">
@for ($i = 1; $i <= 50; $i++)
<img src="/images/emoji/{{ $i }}.gif" width="24" height="24"
style="cursor: pointer; border: 1px solid transparent; border-radius: 2px;"
onmouseover="this.style.borderColor='#336699'" onmouseout="this.style.borderColor='transparent'"
onclick="insertEmoji('[IMG]{{ $i }}[/IMG]')" title="表情{{ $i }}"
onerror="this.style.display='none'">
@endfor
</div>
</div>
{{-- 酷库面板 --}}
<div class="user-list-content" id="panel-action" style="display: none; padding: 6px;">
<div style="font-size: 11px; color: #666; line-height: 2;">
@foreach (['微笑', '大笑', '愤怒', '哭泣', '害羞', '鄙视', '得意', '疑惑', '同情', '无奈', '拳打', '飞吻', '偷看', '战战兢兢'] as $act)
<a href="#" onclick="setAction('{{ $act }}'); return false;"
style="display: inline-block; padding: 2px 6px; background: #f0f6ff; border: 1px solid #dde8ff; border-radius: 3px; margin: 1px; color: #336699; font-size: 11px;">{{ $act }}</a>
@endforeach
</div>
</div>
{{-- 底部在线统计 --}}
<div class="online-stats">
在线: <strong id="online-count-footer">0</strong>
</div>
</div>
@@ -0,0 +1,934 @@
{{--
文件功能:聊天室前端交互脚本(Blade 模板形式)
包含消息渲染、用户列表管理、发送消息、WebSocket 事件监听、
管理操作、存点心跳、头像选择等全部前端逻辑
通过 @include('chat.partials.scripts') 引入到 frame.blade.php
@author ChatRoom Laravel
@version 1.0.0
--}}
<script>
/**
* 聊天室前端交互逻辑
* 保留所有 WebSocket 事件监听,复刻原版 UI 交互
*/
// ── DOM 元素引用 ──────────────────────────────────────
const container = document.getElementById('chat-messages-container');
const container2 = document.getElementById('chat-messages-container2');
const userList = document.getElementById('online-users-list');
const toUserSelect = document.getElementById('to_user');
const onlineCount = document.getElementById('online-count');
const onlineCountBottom = document.getElementById('online-count-bottom');
let onlineUsers = {};
let autoScroll = true;
// ── Tab 切换 ──────────────────────────────────────
function switchTab(tab) {
['users', 'rooms', 'emoji', 'action'].forEach(t => {
document.getElementById('panel-' + t).style.display = t === tab ? 'block' : 'none';
document.getElementById('tab-' + t).classList.toggle('active', t === tab);
});
}
// ── 分屏切换 ──────────────────────────────────────
function changeSplitScreen(mode) {
const panes = document.getElementById('message-panes');
if (mode === '1') {
panes.classList.add('split-h');
} else {
panes.classList.remove('split-h');
}
}
// ── 表情插入 ──────────────────────────────────────
function insertEmoji(code) {
const input = document.getElementById('content');
input.value += code;
input.focus();
switchTab('users');
}
// ── 动作选择 ──────────────────────────────────────
function setAction(act) {
document.getElementById('action').value = act;
switchTab('users');
document.getElementById('content').focus();
}
// ── 自动滚屏 ──────────────────────────────────────
document.getElementById('auto_scroll').addEventListener('change', function() {
autoScroll = this.checked;
});
// ── 滚动到底部 ───────────────────────────────────
function scrollToBottom() {
if (autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
// ── 渲染在线人员列表(支持排序) ──────────────────
function renderUserList() {
userList.innerHTML = '';
toUserSelect.innerHTML = '<option value="大家">大家</option>';
// 在列表顶部添加"大家"条目(原版风格)
let allDiv = document.createElement('div');
allDiv.className = 'user-item';
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
allDiv.onclick = () => {
toUserSelect.value = '大家';
};
userList.appendChild(allDiv);
// 获取排序方式
const sortSelect = document.getElementById('user-sort-select');
const sortBy = sortSelect ? sortSelect.value : 'default';
// 构建用户数组并排序
let userArr = [];
for (let username in onlineUsers) {
userArr.push({
username,
...onlineUsers[username]
});
}
if (sortBy === 'name') {
userArr.sort((a, b) => a.username.localeCompare(b.username, 'zh'));
} else if (sortBy === 'level') {
userArr.sort((a, b) => (b.user_level || 0) - (a.user_level || 0));
}
let count = userArr.length;
userArr.forEach(user => {
const username = user.username;
let item = document.createElement('div');
item.className = 'user-item';
item.dataset.username = username;
const headface = user.headface || '1.GIF';
item.innerHTML = `
<img class="user-head" src="/images/headface/${headface}" onerror="this.src='/images/headface/1.GIF'">
<span class="user-name">${username}</span>
`;
item.onclick = () => {
toUserSelect.value = username;
document.getElementById('content').focus();
};
item.ondblclick = () => {
if (username !== window.chatContext.username) {
showUserInfoInSay2(username);
}
};
userList.appendChild(item);
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
option.textContent = username;
toUserSelect.appendChild(option);
}
});
onlineCount.innerText = count;
onlineCountBottom.innerText = count;
const footer = document.getElementById('online-count-footer');
if (footer) footer.innerText = count;
// 如果有搜索关键词,重新过滤
filterUserList();
}
/**
* 搜索/过滤用户列表
*/
function filterUserList() {
const searchInput = document.getElementById('user-search-input');
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : '';
const items = userList.querySelectorAll('.user-item');
items.forEach(item => {
if (!keyword) {
item.style.display = '';
return;
}
const name = (item.dataset.username || item.textContent || '').toLowerCase();
item.style.display = name.includes(keyword) ? '' : 'none';
});
}
// ── 点击消息中的用户名,切换发言对象 ──────────
function switchTarget(username) {
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();
}
/**
* 双击用户名 在包厢窗口(say2)显示用户基本信息
*/
async function showUserInfoInSay2(username) {
try {
const res = await fetch('/user/' + encodeURIComponent(username));
const info = await res.json();
const sexText = info.sex === '女' ? '女' : '男';
const level = info.user_level || 0;
const exp = info.exp_num || 0;
const jjb = info.jjb || 0;
const sign = info.qianming || info.sign || '暂无';
const lines = [
`══════ <b style="color:#336699;">${info.username || username}</b> 的资料 ══════`,
`性别:${sexText} 等级:${level} 经验:${exp} 金币:${jjb}`,
`签名:${sign}`,
`════════════════════════`
];
lines.forEach(text => {
const div = document.createElement('div');
div.className = 'msg-line';
div.innerHTML = `<span style="color:#666; font-size:12px;">${text}</span>`;
container2.appendChild(div);
});
container2.scrollTop = container2.scrollHeight;
} catch (e) {
console.error('获取用户资料失败:', e);
}
}
/**
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)
*/
function appendMessage(msg) {
const isMe = msg.from_user === window.chatContext.username;
const fontColor = msg.font_color || '#000000';
const div = document.createElement('div');
div.className = 'msg-line';
const timeStr = msg.sent_at || '';
// 用户名(单击切换发言对象,双击查看资料)
const clickableUser = (uName, color) =>
`<span class="msg-user" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="showUserInfoInSay2('${uName}')">${uName}</span>`;
// 获取发言者头像
const senderInfo = onlineUsers[msg.from_user];
const senderHead = (senderInfo && senderInfo.headface) || '1.GIF';
const headImg =
`<img src="/images/headface/${senderHead}" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;" onerror="this.src='/images/headface/1.GIF'">`;
let html = '';
if (msg.is_secret) {
// 悄悄话样式(原版:紫色斜体)
html =
`<span class="msg-secret">${headImg}${clickableUser(msg.from_user, '#cc00cc')}对${clickableUser(msg.to_user, '#cc00cc')}`;
if (msg.action) html += `${msg.action}`;
html += `悄悄说:${msg.content}</span>`;
} else if (msg.to_user && msg.to_user !== '大家') {
html = `${headImg}${clickableUser(msg.from_user, '#000099')}对${clickableUser(msg.to_user, '#000099')}`;
if (msg.action) html += `${msg.action}`;
html += `说:<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
} else {
html = `${headImg}${clickableUser(msg.from_user, '#000099')}对大家`;
if (msg.action) html += `${msg.action}`;
html += `说:<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
}
html += ` <span class="msg-time">(${timeStr})</span>`;
div.innerHTML = html;
// 路由规则(复刻原版):
// 公众窗口(say1):别人的公聊消息
// 包厢窗口(say2):自己发的消息 + 悄悄话 + 对自己说的消息
const isRelatedToMe = isMe ||
msg.is_secret ||
msg.to_user === window.chatContext.username;
if (isRelatedToMe) {
container2.appendChild(div);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
container.appendChild(div);
scrollToBottom();
}
}
// ── WebSocket 初始化 ─────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
}
});
// ── WebSocket 事件监听 ────────────────────────────
window.addEventListener('chat:here', (e) => {
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
});
renderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
renderUserList();
// 原版风格:完整句式的随机趣味欢迎语
const gender = user.sex === '女' ? '美女' : '帅哥';
const uname = user.username;
const welcomeTemplates = [
`${gender}<b>${uname}</b>开着刚买不久的车,来到了,见到各位大虾,拱手曰:"众位大虾,小生有礼了"`,
`${gender}<b>${uname}</b>骑着小毛驴哼着小调,悠闲地走了进来,对大家嘿嘿一笑`,
`${gender}<b>${uname}</b>坐着豪华轿车缓缓驶入,推门而出,拍了拍身上的灰,霸气说道:"我来也!"`,
`${gender}<b>${uname}</b>踩着七彩祥云从天而降,众人皆惊,抱拳道:"各位久等了!"`,
`${gender}<b>${uname}</b>划着小船飘然而至,微微一笑,翩然上岸`,
`${gender}<b>${uname}</b>骑着自行车铃铛叮当响,远远就喊:"我来啦!想我没?"`,
`${gender}<b>${uname}</b>开着拖拉机突突突地开了进来,下车后拍了拍手说:"交通不便,来迟了!"`,
`${gender}<b>${uname}</b>坐着火箭嗖的一声到了,吓了大家一跳,嘿嘿笑道:"别怕别怕,是我啊"`,
`${gender}<b>${uname}</b>骑着白马翩翩而来,英姿飒爽,拱手道:"江湖路远,各位有礼了"`,
`${gender}<b>${uname}</b>开着宝马一路飞驰到此,推开车门走了下来,向大家挥了挥手`,
`${gender}<b>${uname}</b>踩着风火轮呼啸而至,在人群中潇洒亮相`,
`${gender}<b>${uname}</b>乘坐滑翔伞从天空缓缓降落,对大家喊道:"hello,我从天上来!"`,
`${gender}<b>${uname}</b>从地下钻了出来,拍了拍土,说:"哎呀,走错路了,不过总算到了"`,
`${gender}<b>${uname}</b>蹦蹦跳跳地跑了进来,嘻嘻哈哈地跟大家打招呼`,
`${gender}<b>${uname}</b>悄悄地溜了进来,生怕被人发现,东张西望了一番`,
`${gender}<b>${uname}</b>迈着六亲不认的步伐走进来,气场两米八`,
];
const msg = welcomeTemplates[Math.floor(Math.random() * welcomeTemplates.length)];
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML =
`<span style="color: green">【欢迎】${msg}</span><span class="msg-time">(${timeStr})</span>`;
container.appendChild(sysDiv);
scrollToBottom();
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
renderUserList();
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line sys-msg';
sysDiv.innerHTML = `<span style="color: gray">☆ ${user.username} 离开了聊天室 ☆</span>`;
container.appendChild(sysDiv);
scrollToBottom();
});
window.addEventListener('chat:message', (e) => {
const msg = e.detail;
if (msg.is_secret && msg.from_user !== window.chatContext.username && msg.to_user !== window
.chatContext.username) {
return;
}
appendMessage(msg);
});
window.addEventListener('chat:kicked', (e) => {
if (e.detail.username === window.chatContext.username) {
alert("您已被管理员踢出房间!");
window.location.href = "{{ route('rooms.index') }}";
}
});
// ── 禁言状态(本地计时器) ──
let isMutedUntil = 0;
window.addEventListener('chat:muted', (e) => {
const d = e.detail;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
// 在聊天窗口显示禁言通知
const div = document.createElement('div');
div.className = 'msg-line';
div.innerHTML =
`<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
container.appendChild(div);
if (autoScroll) container.scrollTop = container.scrollHeight;
// 如果是自己被禁言,设置本地禁言计时
if (d.username === window.chatContext.username && d.mute_time > 0) {
isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
const contentInput = document.getElementById('content');
if (contentInput) {
contentInput.placeholder = `您已被禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
contentInput.disabled = true;
// 到期自动恢复
setTimeout(() => {
isMutedUntil = 0;
contentInput.placeholder = '在这里输入聊天内容,按 Enter 发送...';
contentInput.disabled = false;
const unmuteDiv = document.createElement('div');
unmuteDiv.className = 'msg-line';
unmuteDiv.innerHTML =
'<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
container.appendChild(unmuteDiv);
if (autoScroll) container.scrollTop = container.scrollHeight;
}, d.mute_time * 60 * 1000);
}
}
});
window.addEventListener('chat:title-updated', (e) => {
document.getElementById('room-title-display').innerText = e.detail.title;
});
// ── 发送消息(Enter 发送) ───────────────────────
document.getElementById('content').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(e);
}
});
/**
* 发送聊天消息
*/
async function sendMessage(e) {
if (e) e.preventDefault();
// 前端禁言检查
if (isMutedUntil > Date.now()) {
const remaining = Math.ceil((isMutedUntil - Date.now()) / 1000);
alert(`您正在禁言中,还有 ${remaining} 秒后解禁。`);
return;
}
const form = document.getElementById('chat-form');
const formData = new FormData(form);
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const content = formData.get('content').trim();
if (!content) {
contentInput.focus();
return;
}
submitBtn.disabled = true;
try {
const response = await fetch(window.chatContext.sendUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
},
body: formData
});
const data = await response.json();
if (response.ok && data.status === 'success') {
contentInput.value = '';
contentInput.focus();
} else {
alert('发送失败: ' + (data.message || JSON.stringify(data.errors)));
}
} catch (error) {
alert('网络连接错误,消息发送失败!');
console.error(error);
} finally {
submitBtn.disabled = false;
}
}
// ── 管理操作执行(下拉选择 + 目标用户) ─────────────
async function executeAdminAction() {
const select = document.getElementById('admin-action-select');
if (!select) return;
const action = select.value;
if (!action) {
alert('请先选择一个管理操作。');
return;
}
// 获取聊天对象下拉框中选中的用户作为目标
const toUserSelect = document.getElementById('to_user');
const targetUser = toUserSelect ? toUserSelect.value : '';
if (!targetUser || targetUser === '大家') {
alert('请先在「对...说」下拉中选择要操作的用户。');
return;
}
// 操作名称映射
const actionNames = {
kick: '踢出',
mute: '禁言',
ban: '封号',
banip: '封IP'
};
const actionName = actionNames[action] || action;
// 禁言需要输入时长
let duration = 5;
if (action === 'mute') {
const input = prompt('请输入禁言时长(分钟):', '5');
if (!input) return;
duration = parseInt(input) || 5;
}
// 二次确认
const confirmMsg = action === 'banip' ?
`⚠️ 严重操作:确定要封禁 ${targetUser} 的IP地址吗?该用户将被封号+封IP!` :
`确定要对 ${targetUser} 执行「${actionName}」操作吗?`;
if (!confirm(confirmMsg)) return;
try {
const body = {
room_id: window.chatContext.roomId
};
if (action === 'mute') body.duration = duration;
const res = await fetch(`/user/${encodeURIComponent(targetUser)}/${action}`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(body)
});
const data = await res.json();
alert(data.message);
} catch (e) {
alert('操作失败:' + e.message);
}
select.value = ''; // 重置下拉
}
// ── 设置房间公告 ─────────────────────────────────────
async function promptAnnouncement() {
const currentText = document.getElementById('announcement-text')?.textContent?.trim() || '';
const newText = prompt('请输入新的房间公告/祝福语:', currentText);
if (newText === null || newText.trim() === '') return;
try {
const res = await fetch(`/room/${window.chatContext.roomId}/announcement`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
announcement: newText.trim()
})
});
const data = await res.json();
if (data.status === 'success') {
const marquee = document.getElementById('announcement-text');
if (marquee) marquee.textContent = newText.trim();
alert('公告已更新!');
} else {
alert(data.message || '更新失败');
}
} catch (e) {
alert('设置公告失败:' + e.message);
}
}
// ── 滚屏开关 ─────────────────────────────────────
function toggleAutoScroll() {
autoScroll = !autoScroll;
const cb = document.getElementById('auto_scroll');
if (cb) cb.checked = autoScroll;
const statusEl = document.getElementById('scroll-status');
if (statusEl) statusEl.textContent = autoScroll ? '开' : '关';
}
// ── 退出房间 ─────────────────────────────────────
async function leaveRoom() {
try {
await fetch(window.chatContext.leaveUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
} catch (e) {
console.error(e);
}
// 弹出窗口直接关闭,如果不是弹出窗口则跳回首页
window.close();
setTimeout(() => {
window.location.href = '/';
}, 500);
}
// ── 掉线检测计数器 ──
let heartbeatFailCount = 0;
const MAX_HEARTBEAT_FAILS = 3;
// ── 存点功能(手动 + 自动)─────────────────────
async function saveExp(silent = false) {
if (!window.chatContext || !window.chatContext.heartbeatUrl) return;
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')
.getAttribute('content'),
'Accept': 'application/json'
}
});
// 检测登录态失效
if (response.status === 401 || response.status === 419) {
alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。');
window.location.href = '/';
return;
}
const data = await response.json();
if (response.ok && data.status === 'success') {
heartbeatFailCount = 0;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const d = data.data;
const levelTitle = d.user_level >= 10 ? '管理员' : (d.user_level >= 5 ? '高级会员' : (d
.user_level >= 3 ?
'三级会员' : '普通会员'));
let levelInfo = '';
if (d.is_max_level) {
levelInfo = `级别(${d.user_level});累积经验(${d.exp_num});已满级。`;
} else {
const requiredExp = d.user_level * d.user_level * 10;
const remaining = Math.max(0, requiredExp - d.exp_num);
levelInfo = `级别(${d.user_level});累积经验(${d.exp_num});还有(${remaining})升级。`;
}
if (data.data.leveled_up) {
const upDiv = document.createElement('div');
upDiv.className = 'msg-line';
upDiv.innerHTML =
`<span style="color: #d97706; font-weight: bold;">【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(upDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}
if (!silent) {
const detailDiv = document.createElement('div');
detailDiv.className = 'msg-line';
detailDiv.innerHTML =
`<span style="color: green;">【${levelTitle}存点】您的最新情况:${levelInfo}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(detailDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} else {
return;
}
}
} catch (e) {
console.error('存点失败', e);
heartbeatFailCount++;
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
alert('⚠️ 与服务器的连接已断开,请检查网络后重新登录。');
window.location.href = '/';
return;
}
if (!silent) {
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
sysDiv.innerHTML = `<span style="color: red;">【系统】存点失败,请稍后重试</span>`;
container2.appendChild(sysDiv);
}
}
}
// ── 自动存点心跳(每60秒自动存一次)───────────
const HEARTBEAT_INTERVAL = 60 * 1000;
setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
setTimeout(() => saveExp(true), 10000);
// ── 头像选择器(原版 fw.asp 功能)───────────────
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>';
}
}
/**
* 选中一个头像
*/
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 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') {
alert('头像修改成功!');
const myName = window.chatContext.username;
if (onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
renderUserList();
closeAvatarPicker();
} else {
alert(data.message || '修改失败');
}
} catch (e) {
alert('网络错误');
}
btn.disabled = false;
btn.textContent = '确定更换';
}
// ── 钓鱼小游戏(复刻原版 diaoyu/ 功能)─────────────
let fishingTimer = null;
let fishingReelTimeout = null;
/**
* 开始钓鱼 调用抛竿 API,花费金币,显示等待动画
*/
async function startFishing() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
try {
const res = await fetch(window.chatContext.fishCastUrl, {
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(data.message || '钓鱼失败');
btn.disabled = false;
return;
}
// 在包厢窗口显示抛竿消息
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const castDiv = document.createElement('div');
castDiv.className = 'msg-line';
castDiv.innerHTML =
`<span style="color: #2563eb; font-weight: bold;">🎣【钓鱼】</span>${data.message}<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(castDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
// 等待鱼上钩(后端返回的随机等待秒数)
btn.textContent = '🎣 等待中...';
fishingTimer = setTimeout(() => {
// 鱼上钩了!
const hookDiv = document.createElement('div');
hookDiv.className = 'msg-line';
hookDiv.innerHTML =
'<span style="color: #d97706; font-weight: bold; font-size: 14px;">🐟 鱼上钩了!快点击 [拉竿] 按钮!</span>';
container2.appendChild(hookDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
btn.textContent = '🎣 拉竿!';
btn.disabled = false;
btn.onclick = reelFish;
// 15 秒内不拉竿,鱼跑掉
fishingReelTimeout = setTimeout(() => {
const missDiv = document.createElement('div');
missDiv.className = 'msg-line';
missDiv.innerHTML =
'<span style="color: #999;">💨 你反应太慢了,鱼跑掉了...</span>';
container2.appendChild(missDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
resetFishingBtn();
}, 15000);
}, data.wait_time * 1000);
} catch (e) {
alert('网络错误:' + e.message);
btn.disabled = false;
}
}
/**
* 拉竿 调用收竿 API,获取随机结果
*/
async function reelFish() {
const btn = document.getElementById('fishing-btn');
btn.disabled = true;
btn.textContent = '🎣 拉竿中...';
// 取消跑鱼计时器
if (fishingReelTimeout) {
clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
try {
const res = await fetch(window.chatContext.fishReelUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await res.json();
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
if (res.ok && data.status === 'success') {
const r = data.result;
const color = r.exp >= 0 ? '#16a34a' : '#dc2626';
const resultDiv = document.createElement('div');
resultDiv.className = 'msg-line';
resultDiv.innerHTML =
`<span style="color: ${color}; font-weight: bold;">${r.emoji}【钓鱼结果】</span>${r.message}` +
` <span style="color: #666; font-size: 11px;">(当前经验:${data.exp_num} 金币:${data.jjb}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
container2.appendChild(resultDiv);
} else {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML =
`<span style="color: red;">【钓鱼】${data.message || '操作失败'}</span><span class="msg-time">(${timeStr})</span>`;
container2.appendChild(errDiv);
}
if (autoScroll) container2.scrollTop = container2.scrollHeight;
} catch (e) {
alert('网络错误:' + e.message);
}
resetFishingBtn();
}
/**
* 重置钓鱼按钮状态
*/
function resetFishingBtn() {
const btn = document.getElementById('fishing-btn');
btn.textContent = '🎣 钓鱼';
btn.disabled = false;
btn.onclick = startFishing;
fishingTimer = null;
fishingReelTimeout = null;
}
</script>
@@ -0,0 +1,32 @@
{{--
文件功能:聊天室竖向工具条(中间导航栏)
frame.blade.php 拆分,便于独立维护
依赖变量:$user, $superLevel, $room
--}}
<div class="chat-toolbar" id="toolbar-strip">
@if ($user->user_level >= $superLevel)
<div class="tool-btn" style="color: #ffcc00;" onclick="window.open('/admin', '_blank')" title="管理后台">
管理人员</div>
@endif
<div class="tool-btn" onclick="window.open('{{ route('rooms.index') }}', '_blank')" title="商店">商店
</div>
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
<div class="tool-btn" onclick="window.open('{{ route('leaderboard.index') }}', '_blank')" title="排行/娱乐">
娱乐
</div>
<div class="tool-btn" onclick="window.open('{{ route('guestbook.index') }}', '_blank')" title="银行">
银行
</div>
<div class="tool-btn" onclick="switchTab('users')" title="呼叫在线用户">呼叫</div>
<div class="tool-btn" onclick="openAvatarPicker()" title="修改头像">头像
</div>
<div class="tool-btn" onclick="window.open('{{ route('guestbook.index') }}', '_blank')" title="提议/建议">
提议</div>
<div class="tool-btn" onclick="window.open('{{ route('guestbook.index') }}', '_blank')" title="留言板/私信">
留言</div>
<div class="tool-btn" onclick="window.open('/admin', '_blank')" title="管理">管理</div>
<div class="tool-btn" style="color: #ffaaaa;" onclick="if(confirm('确定要离开聊天室吗?')){leaveRoom();}" title="离开聊天室">离开
</div>
</div>