修复:排行榜/留言板缺失布局、退出登录跳转、WebSocket 配置与部署文档

- 修复 LeaderboardController 查询不存在的 sign 字段导致 500 错误
- 修复 leaderboard/index 和 guestbook/index 引用不存在的 layouts.app 布局
- 将排行榜和留言板改为独立 HTML 页面结构(含 Tailwind CDN)
- 修复退出登录返回 JSON 而非重定向的问题,现在会正确跳转回登录页
- 将 REDIS_CLIENT 从 phpredis 改为 predis(兼容无扩展环境)
- 新增 RoomSeeder 自动创建默认公共大厅房间
- 新增 Nginx 生产环境配置示例(含 WebSocket 反向代理)
- 重写 README.md 为完整的中文部署指南
- 修复 rooms/index 和 chat/frame 中 Alpine.js 语法错误
- 将 chat.js 加入 Vite 构建配置
- 新增验证码配置文件
This commit is contained in:
2026-02-26 14:57:24 +08:00
parent 50fc804402
commit d884853968
19 changed files with 1083 additions and 458 deletions
+309 -261
View File
@@ -151,21 +151,55 @@
// 执行踢出
async kickUser() {
if (!confirm('确定要将 ' + this.userInfo.username + ' 踢出房间吗?')) return;
try {
const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/kick', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type'
: 'application/json' , 'Accept' : 'application/json' }, body: JSON.stringify({ room_id:
window.chatContext.roomId }) }); const data=await res.json(); if(data.status === 'success') {
this.showUserModal=false; } else { alert('操作失败:' + data.message); } } catch (e) { alert('网络异常'); } }, // 执行禁言
async muteUser() { try { const res=await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/mute' ,
{ method: 'POST' , headers: { 'X-CSRF-TOKEN' :
document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type' : 'application/json'
, 'Accept' : 'application/json' }, body: JSON.stringify({ room_id: window.chatContext.roomId, duration:
this.muteDuration }) }); const data=await res.json(); if(data.status === 'success') { alert(data.message);
this.showUserModal=false; } else { alert('操作失败:' + data.message); } } catch (e) { alert('网络异常'); } } }">
if (!confirm('确定要将 ' + this.userInfo.username + ' 踢出房间吗?')) return;
try {
const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/kick', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ room_id: window.chatContext.roomId })
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
}
} catch (e) {
alert('网络异常');
}
},
// 执行禁言
async muteUser() {
try {
const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/mute', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
duration: this.muteDuration
})
});
const data = await res.json();
if (data.status === 'success') {
alert(data.message);
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
}
} catch (e) {
alert('网络异常');
}
}
}">
<!-- 用户名片弹窗 -->
<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">
@@ -184,305 +218,319 @@
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'">
@@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>
<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>
<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>
<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 }}">
<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>
</div> @endif
<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 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>
飞鸽传书 (发私信)
</a>
<!-- 禁言表单 -->
<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>
</div>
</div>
@endif
</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>
飞鸽传书 (发私信)
</a>
</div>
</div>
</div>
</div>
<script>
// 核心页面交互逻辑,连接 chat.js 抛出的自定义事件
<script>
// 核心页面交互逻辑,连接 chat.js 抛出的自定义事件
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');
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');
let onlineUsers = {}; // 用于本地维护在线名单
let onlineUsers = {}; // 用于本地维护在线名单
// 辅助:滚动到底部
function scrollToBottom() {
container.scrollTop = container.scrollHeight;
}
// 辅助:滚动到底部
function scrollToBottom() {
container.scrollTop = container.scrollHeight;
}
// 辅助:渲染在线人员列表
function renderUserList() {
userList.innerHTML = '';
// 同时更新“对谁说”下拉框(保留大家选项)
toUserSelect.innerHTML = '<option value="大家">所有人</option>';
// 辅助:渲染在线人员列表
function renderUserList() {
userList.innerHTML = '';
// 同时更新“对谁说”下拉框(保留大家选项)
toUserSelect.innerHTML = '<option value="大家">所有人</option>';
let count = 0;
for (let username in onlineUsers) {
count++;
let user = onlineUsers[username];
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 = `
// 渲染右侧面板
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);
}
// 单击右侧列表可以快速查看资料 / @ 人
li.onclick = () => {
toUserSelect.value = username;
// 触发 Alpine 挂载的查看名片方法
const modalScope = document.querySelector('[x-data]').__x.$data;
if (modalScope && username !== window.chatContext.username) {
modalScope.fetchUser(username);
}
onlineCount.innerText = count;
};
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}` : '';
// 辅助:渲染单条消息气泡
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 = '';
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>`;
// 辅助:生成可点击的用户名 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} 说:`;
}
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`;
const div = document.createElement('div');
div.className = `flex ${alignClass} mb-3 group`;
let html = `
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();
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');
}
}
// 🚀 初始化 WebSocket 监听器
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
// 🚪 退出房间逻辑
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'
}
});
// 🔌 监听 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');
}
const data = await response.json();
if (response.ok && data.status === 'success') {
// 可选:在这里如果需要更新自己的名片经验条,可触发 Alpine 等级更新(如果实现了前台独立显示自己经验的功能的话)
console.log('心跳存点成功,当前经验值:' + data.data.exp_num + ', 等级:' + data.data.user_level);
}
// 🚪 退出房间逻辑
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>
} catch (e) {
console.error('挂机心跳断开', e);
}
}, HEARTBEAT_INTERVAL);
</script>
</body>
</html>
+19 -10
View File
@@ -1,8 +1,15 @@
@extends('layouts.app')
<!DOCTYPE html>
<html lang="zh-CN">
@section('title', '星光留言板')
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>星光留言板 - 飘落流星</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
@section('content')
<body class="bg-gray-50 flex h-screen overflow-hidden text-sm">
<div class="h-screen w-full flex flex-col bg-gray-50 overflow-hidden font-sans" x-data="{ showWriteForm: false, towho: '{{ $defaultTo }}' }">
<!-- 顶部导航条 -->
@@ -53,7 +60,7 @@
<p>{{ session('error') }}</p>
</div>
@endif
@if ($errors->any())
@if (isset($errors) && $errors->any())
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4 mx-4 mt-4 shadow-sm">
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
@@ -137,8 +144,8 @@
@php
// 判断是否属于自己发或收的悄悄话,用于高亮
$isSecret = $msg->secret == 1;
$isToMe = $msg->towho === Auth::user()->username;
$isFromMe = $msg->who === Auth::user()->username;
$isToMe = Auth::check() && $msg->towho === Auth::user()->username;
$isFromMe = Auth::check() && $msg->who === Auth::user()->username;
@endphp
<div
@@ -164,7 +171,7 @@
<span>{{ \Carbon\Carbon::parse($msg->post_time)->diffForHumans() }}</span>
<!-- 删除按钮 (只有发件人、收件人、超管可见) -->
@if ($isFromMe || $isToMe || Auth::user()->user_level >= 15)
@if ($isFromMe || $isToMe || (Auth::check() && Auth::user()->user_level >= 15))
<form action="{{ route('guestbook.destroy', $msg->id) }}" method="POST"
onsubmit="return confirm('确定要抹除这条留言吗?');" class="inline">
@csrf
@@ -184,7 +191,7 @@
</div>
<!-- 快捷回复按钮 -->
@if ($msg->who !== Auth::user()->username)
@if (!Auth::check() || $msg->who !== Auth::user()->username)
<div class="mt-3 flex justify-end">
<button
@click="showWriteForm = true; towho = '{{ $msg->who }}'; setTimeout(() => $refs.textBody.focus(), 100); window.scrollTo({top:0, behavior:'smooth'})"
@@ -223,7 +230,7 @@
</div>
<!-- 移动端底部分类栏 -->
<div class="md:hidden bg-white border-t border-gray-200 flex justify-around p-2 shrink-0">
<div class="md:hidden bg-white border-t border-gray-200 flex justify-around p-2 shrink-0 relative z-20">
<a href="{{ route('guestbook.index', ['tab' => 'public']) }}"
class="flex flex-col items-center {{ $tab === 'public' ? 'text-indigo-600' : 'text-gray-500' }}">
<span class="text-xl">🌍</span>
@@ -241,4 +248,6 @@
</a>
</div>
@endsection
</body>
</html>
+86 -77
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -8,13 +9,14 @@
<script src="https://cdn.tailwindcss.com"></script>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body class="bg-gray-100 h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center mb-6 text-gray-800">
{{ \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '在线聊天室' }}
</h1>
<p class="text-sm text-gray-500 text-center mb-6">
{{ \App\Models\SysParam::where('alias', 'sys_notice')->value('body') ?? '欢迎您的加入' }}
</p>
@@ -26,105 +28,112 @@
<div>
<label for="username" class="block text-sm font-medium text-gray-700">昵称 (第一次登录即注册)</label>
<input type="text" id="username" name="username" required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="允许中英文、数字、下划线">
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="允许中英文、数字、下划线">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">密码</label>
<input type="password" id="password" name="password" required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="请输入密码">
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="请输入密码">
</div>
<div>
<label for="captcha" class="block text-sm font-medium text-gray-700">验证码</label>
<div class="mt-1 flex space-x-2">
<input type="text" id="captcha" name="captcha" required
class="block w-2/3 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="输入右侧字符">
class="block w-2/3 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="输入右侧字符">
<div class="w-1/3 cursor-pointer" onclick="refreshCaptcha()">
<!-- 验证码图片,点击刷新 -->
<img src="{{ captcha_src() }}" alt="验证码" id="captcha-img" class="h-full w-full rounded-md border border-gray-300 object-cover">
<img src="{{ captcha_src() }}" alt="验证码" id="captcha-img"
class="h-full w-full rounded-md border border-gray-300 object-cover">
</div>
</div>
</div>
<button type="submit" id="submit-btn"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
进入聊天室
</button>
</form>
</div>
<script>
// 刷新验证码
function refreshCaptcha() {
document.getElementById('captcha-img').src = '{{ captcha_src() }}' + Math.random();
}
// 提交登录表单
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const alertBox = document.getElementById('alert-box');
btn.disabled = true;
btn.innerText = '正在进入...';
alertBox.classList.add('hidden');
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('{{ route("login.post") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json().then(data => ({ status: response.status, body: data })))
.then(result => {
if (result.status === 200 && result.body.status === 'success') {
// 登录成功,显示成功并跳转
showAlert(result.body.message, 'success');
setTimeout(() => {
// TODO: 之后重定向到真实的聊天室页面 /chat
window.location.href = '/';
}, 1000);
} else {
// 验证失败或密码错误
const errorMsg = result.body.message || (result.body.errors ? Object.values(result.body.errors)[0][0] : '登录失败');
showAlert(errorMsg, 'error');
refreshCaptcha();
document.getElementById('captcha').value = '';
btn.disabled = false;
btn.innerText = '进入聊天室';
}
})
.catch(error => {
console.error('Error:', error);
showAlert('网络或服务器错误,请稍后再试。', 'error');
refreshCaptcha();
btn.disabled = false;
btn.innerText = '进入聊天室';
});
});
function showAlert(message, type) {
const box = document.getElementById('alert-box');
box.innerText = message;
box.classList.remove('hidden', 'bg-red-100', 'text-red-700', 'bg-green-100', 'text-green-700');
if (type === 'error') {
box.classList.add('bg-red-100', 'text-red-700');
} else {
box.classList.add('bg-green-100', 'text-green-700');
<script>
// 刷新验证码
function refreshCaptcha() {
document.getElementById('captcha-img').src = '{{ captcha_src() }}' + Math.random();
}
}
</script>
// 提交登录表单
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const alertBox = document.getElementById('alert-box');
btn.disabled = true;
btn.innerText = '正在进入...';
alertBox.classList.add('hidden');
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('{{ route('login.post') }}', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json().then(data => ({
status: response.status,
body: data
})))
.then(result => {
if (result.status === 200 && result.body.status === 'success') {
// 登录成功,显示成功并跳转
showAlert(result.body.message, 'success');
setTimeout(() => {
// 转跳到大厅房间列表
window.location.href = '/rooms';
}, 1000);
} else {
// 验证失败或密码错误
const errorMsg = result.body.message || (result.body.errors ? Object.values(result.body
.errors)[0][0] : '登录失败');
showAlert(errorMsg, 'error');
refreshCaptcha();
document.getElementById('captcha').value = '';
btn.disabled = false;
btn.innerText = '进入聊天室';
}
})
.catch(error => {
console.error('Error:', error);
showAlert('网络或服务器错误,请稍后再试。', 'error');
refreshCaptcha();
btn.disabled = false;
btn.innerText = '进入聊天室';
});
});
function showAlert(message, type) {
const box = document.getElementById('alert-box');
box.innerText = message;
box.classList.remove('hidden', 'bg-red-100', 'text-red-700', 'bg-green-100', 'text-green-700');
if (type === 'error') {
box.classList.add('bg-red-100', 'text-red-700');
} else {
box.classList.add('bg-green-100', 'text-green-700');
}
}
</script>
</body>
</html>
+30 -13
View File
@@ -1,8 +1,14 @@
@extends('layouts.app')
<!DOCTYPE html>
<html lang="zh-CN">
@section('title', '风云排行榜')
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>风云排行榜 - 飘落流星</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
@section('content')
<body class="bg-gray-100 flex h-screen overflow-hidden text-sm">
<div class="h-screen w-full flex flex-col bg-gray-100 overflow-hidden font-sans">
<!-- 顶部导航条 -->
@@ -29,12 +35,18 @@
<!-- 右侧:当前用户状态 -->
<div class="flex items-center space-x-3 text-sm">
<img src="/images/headface/{{ Auth::user()->headface ?? '01.gif' }}"
class="w-8 h-8 rounded border border-indigo-500 object-cover bg-white">
<div class="hidden sm:block">
<span class="font-bold">{{ Auth::user()->username }}</span>
<span class="text-indigo-300 ml-2">LV.{{ Auth::user()->user_level }}</span>
</div>
@auth
<img src="/images/headface/{{ Auth::user()->headface ?? '01.gif' }}"
class="w-8 h-8 rounded border border-indigo-500 object-cover bg-white">
<div class="hidden sm:block">
<span class="font-bold">{{ Auth::user()->username }}</span>
<span class="text-indigo-300 ml-2">LV.{{ Auth::user()->user_level }}</span>
</div>
@else
<div class="hidden sm:block">
<span class="text-indigo-300">游客状态</span>
</div>
@endauth
</div>
</div>
</div>
@@ -58,7 +70,8 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-red-600 to-red-500 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">👑</span> 无上境界榜</h2>
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">👑</span> 无上境界榜
</h2>
<span class="text-xs bg-red-800/40 px-2 py-0.5 rounded">Level</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@@ -75,7 +88,8 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-amber-600 to-amber-500 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">🔥</span> 苦修经验榜</h2>
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">🔥</span> 苦修经验榜
</h2>
<span class="text-xs bg-amber-800/40 px-2 py-0.5 rounded">Exp</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@@ -110,7 +124,8 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-pink-600 to-pink-500 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">🌸</span> 绝世名伶榜</h2>
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">🌸</span> 绝世名伶榜
</h2>
<span class="text-xs bg-pink-800/40 px-2 py-0.5 rounded">Charm</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@@ -128,4 +143,6 @@
</div>
</main>
</div>
@endsection
</body>
</html>
@@ -15,40 +15,41 @@
$rowBg = 'bg-orange-50 hover:bg-orange-100 border-l-4 border-orange-300';
}
@endphp
<li class="p-3 flex items-center justify-between transition-colors duration-150 {{ $rowBg }}">
<!-- 左侧:名次与头像/名字 -->
<div class="flex items-center space-x-3 overflow-hidden">
<div
class="w-6 h-6 shrink-0 {{ $rankBg }} rounded-full flex items-center justify-center font-bold text-xs">
{{ $index + 1 }}
</div>
<div class="flex items-center space-x-2 truncate">
<img class="w-8 h-8 rounded border object-cover shrink-0"
src="/images/headface/{{ $user->headface ?? '01.gif' }}" alt="">
<div class="flex flex-col truncate">
<span class="text-sm font-bold text-gray-800 truncate" title="{{ $user->username }}">
{{ $user->username }}
@if ($user->sex == '女')
<span class="text-pink-500 text-xs ml-0.5"></span>
@elseif($user->sex == '男')
<span class="text-blue-500 text-xs ml-0.5"></span>
@endif
</span>
<span class="text-[10px] text-gray-500 truncate"
title="{{ $user->sign }}">{{ $user->sign ?: '这家伙很懒,什么也没留下' }}</span>
@if ($user)
<li class="p-3 flex items-center justify-between transition-colors duration-150 {{ $rowBg }}">
<!-- 左侧:名次与头像/名字 -->
<div class="flex items-center space-x-3 overflow-hidden">
<div
class="w-6 h-6 shrink-0 {{ $rankBg }} rounded-full flex items-center justify-center font-bold text-xs">
{{ $index + 1 }}
</div>
<div class="flex items-center space-x-2 truncate">
<img class="w-8 h-8 rounded border object-cover shrink-0"
src="/images/headface/{{ $user->headface ?? '01.gif' }}" alt="">
<div class="flex flex-col truncate">
<span class="text-sm font-bold text-gray-800 truncate" title="{{ $user->username }}">
{{ $user->username }}
@if ($user->sex == '女')
<span class="text-pink-500 text-xs ml-0.5"></span>
@elseif($user->sex == '男')
<span class="text-blue-500 text-xs ml-0.5"></span>
@endif
</span>
<span class="text-[10px] text-gray-500 truncate">暂无个性签名</span>
</div>
</div>
</div>
</div>
<!-- 右侧:数值 -->
<div class="flex flex-col items-end shrink-0 ml-2">
<span class="text-sm font-black {{ $index < 3 ? $color : 'text-gray-600' }}">
{{ number_format($user->$valueField) }}
<span
class="text-[10px] font-normal {{ $index < 3 ? $color : 'text-gray-400' }} ml-0.5">{{ $unit }}</span>
</span>
</div>
</li>
<!-- 右侧:数值 -->
<div class="flex flex-col items-end shrink-0 ml-2">
<span class="text-sm font-black {{ $index < 3 ? $color : 'text-gray-600' }}">
{{ number_format($user->$valueField) }}
<span
class="text-[10px] font-normal {{ $index < 3 ? $color : 'text-gray-400' }} ml-0.5">{{ $unit }}</span>
</span>
</div>
</li>
@endif
@empty
<li class="p-8 text-center text-sm text-gray-400 font-bold">
暂无数据登榜
+6 -5
View File
@@ -315,9 +315,9 @@
const res = await fetch('{{ route('user.update_profile') }}', {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type'
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 'Content-Type'
: 'application/json' , 'Accept' : 'application/json' }, body: JSON.stringify(this.profileData) }); const
data=await res.json(); if(res.ok && data.status === 'success') { alert(data.message);
data=await res.json(); if (res.ok && data.status === 'success') { alert(data.message);
window.location.reload(); } else { alert('保存失败: ' + (data.message || ' 输入有误')); } } catch (e) {
alert('网络异常'); } finally { this.isSaving=false; } } }">
<form @submit.prevent="saveProfile">
@@ -336,7 +336,8 @@
<div class="flex items-center space-x-3">
<div class="w-12 h-12 rounded bg-gray-200 shrink-0 overflow-hidden border">
<img :src="'/images/headface/' + profileData.headface"
@error="$el.style.display='none'" class="w-full h-full object-cover">
@@error="$el.style.display='none'"
class="w-full h-full object-cover">
</div>
<input type="text" x-model="profileData.headface" required
class="flex-1 border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
@@ -396,9 +397,9 @@
const res = await fetch('{{ route('user.update_password') }}', {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type'
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 'Content-Type'
: 'application/json' , 'Accept' : 'application/json' }, body: JSON.stringify(this.pwdData) }); const
data=await res.json(); if(res.ok && data.status === 'success') { alert(data.message);
data=await res.json(); if (res.ok && data.status === 'success') { alert(data.message);
window.location.href = '{{ route('home') }}'; // 改密成功重新登录 } else {
alert('密码修改失败: ' + (data.message || ' 请输入正确的旧密码')); } } catch (e) { alert('网络异常'); } finally {
this.isSaving=false; } } }">