前两天用 AI 搞得聊天室,陆陆续续优化了两天,懒得后续优化了,打包发上来。
注:非插件应用,直接把代码放到网站即可使用。
懒得弄成插件,起初是用 json 存储数据的,但会遇到读取和写入同时进行时,偶发性让记录清空,尝试加上文件锁,但还是有清空情况。
于是现在的版本是改成了数据库存储的,但独立于论坛的数据库。
实现的原理很简单,获取本人已登录的个人信息,发消息时存入数据库即可(所以说其它程序也能使用,只要改一下获取用户信息的部分即可。甚至可以完全去掉改成博客那种留邮箱发消息的效果)。
预览:
![图片[1]|xiuno 的内置聊天室(粗暴版本/非插件版本)|不死鸟资源网](https://www.busi.net/wp-content/uploads/2025/06/20250607103143124-image-650x1024.png)
PS:如有需要,请自行修改文件目录,代码很垃,请勿点评。
html 部分:
<?php if (!empty($uid)): ?>
<style>
.chat-container {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 999;
transition: all 0.3s ease;
}
.chat-box {
width: 340px;
background-color: white;
border-radius: 8px;
box-shadow: 0 0 10px rgb(0 0 0 / 73%);
overflow: hidden;
display: none;
position: relative;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.9);
}
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background-color: #f7f6f1;
border-bottom: 1px solid #e2e8f0;
}
.chat-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.chat-header button {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
color: #333;
outline: none;
/* 取消按钮点击时的边框 */
}
div#chat-box {
position: relative;
overflow: hidden;
background-color: #f7f6f1;
padding: 2px 15px;
overflow-y: scroll;
height: 30rem;
}
.flex.items-start {
align-items: flex-start;
display: flex;
}
.max-w-\[75\%\].flex.flex-col.items-start {
--tw-space-x-reverse: 0;
margin-right: calc(.5rem * var(--tw-space-x-reverse));
margin-left: calc(.5rem * (1 - var(--tw-space-x-reverse)));
align-items: flex-start;
flex-direction: column;
margin-left:.2rem;
}
.text-xs.text-blue-600.font-semibold.mb-1.no-underline {
text-decoration-line: none;
font-size: 0.75rem;
line-height: 1rem;
--tw-text-opacity: 1;
}
.bg-gray-200.mees {
font-size:.875rem;
line-height: 1.25rem;
padding:.5rem;
--tw-bg-opacity: 1;
background-color: #dfdfdf;
border-top-left-radius: 0;
border-radius:.75rem;
word-break: break-all;
border-radius:.75rem;
border-top-left-radius: 0;
color: #6A4A3C;
width: auto;
}
.flex {
display: flex;
}
.input#message-input {
flex: 1;
min-width: 0;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: none;
/* 取消输入框边框 */
border-right: none;
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 2px;
outline: none;
/* 取消输入框聚焦时的边框 */
padding-left: 10px;
}
.text-white {
padding: 0.75rem 1rem;
color: white;
border: none;
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 2px;
transition: all 0.3s ease;
flex-shrink: 0;
width: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-message-left {
margin-right: auto;
}
.chat-message-right {
margin-left: auto;
flex-direction: row-reverse;
}
.chat-message-right.max-w-\[75\%\].flex.flex-col.items-start {
align-items: flex-end;
}
.chat-message-right.bg-gray-200.mees {
border-top-left-radius:.75rem;
border-top-right-radius: 0;
}
.text-xs.text-gray-500 {
font-size: 8px;
color: #9ea4a9;
}
.beta-icon {
color: #2e8786;
font-size: 9px;
font-weight: 400;
position: relative;
top: -11px;
margin-left:.3rem;
}
.unread-indicator {
position: absolute;
top: 20%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #f00;
color: white;
border-radius: 2%;
padding: 2px 10px;
font-size: 10px;
display: none;
}
.last-seen {
text-align: center;
color: #9ea4a9;
font-size: 10px;
margin-top: 5px;
z-index: 30;
}
button#refresh-button {
position: relative;
background: none;
border: none;
cursor: pointer;
font-size: 18px;
color: #333;
outline: none;
/* 取消刷新按钮点击时的边框 */
transition: transform 0.3s ease;
/* 添加过渡效果 */
}
button#refresh-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
input#message-input {
width: 100%;
height: 40px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
box-shadow: none;
padding-left: 10px;
}
.time>span {
display: inline-block;
padding: 0 15px;
font-size: 9pt;
color: #fff;
border-radius: 2px;
background-color: #dcdcdc;
}
p.time {
margin: 9px 0;
text-align: center;
}
/* 缩小图标样式 */
.chat-icon {
position: fixed;
bottom: 40px;
right: 28px;
background-color: #38b2ac;
color: white;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex
;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
input {
border: none;
outline: none;
}
.input#message-input {
/* 新增移动端适配 */
height: 44px; /* 适配移动端输入框高度 */
padding: 0.75rem 1rem;
}
button#send-button {
/* 优化移动端点击区域 */
width: 60px;
}
.border-red-500 {
border: 2px solid #dc2626 !important; /* 红色边框 */
color: #dc2626;
}
img.avatar-chat.w-8.h-8.rounded-full.mr-2 {
border-radius: 1.3rem;
}
.fa-paper-plane:before {
content: "\f1d8";
font-size: 22px;
}
/* 新增声音按钮样式 */
.sound-button {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
color: #333;
outline: none;
transition: color 0.3s ease;
}
.sound-button.muted {
color: #9ea4a9;
}
/* 灯箱样式 */
.lightbox {
display: none; /* 默认隐藏 */
position: absolute;
top: 47px;
left: 0;
width: 100%;
height: 83%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
overflow: auto;
}
.lightbox-content {
position: relative;
max-width: 100%;
text-align: center;
height: auto;
}
#lightbox-image {
max-width: 100%;
max-height: 100vh;
display: block;
margin: 0 auto;
}
.close-lightbox {
position: absolute;
top: 10px;
right: 10px;
color: white;
font-size: 24px;
font-weight: bold;
cursor: pointer;
}
.video-container {
position: relative;
display: inline-block;
}
.play-button {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
cursor: pointer;
z-index: 1;
border-radius: .75rem;
border-top-left-radius: 0;
}
.play-icon {
font-size: 2.5rem;
font-weight: bold;
}
.video-container video {
border-radius: .75rem;
border-top-left-radius: 0;
}
img.chat-image {
border-radius: .75rem;
border-top-left-radius: 0;
display: block;
}
button#send-button {
background-image: linear-gradient(135deg, #38b2ac 0%, #2c7a7b 100%);
color: white;
border: none;
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
transition: all 0.3s ease;
flex-shrink: 0;
width: 48px;
display: flex
;
align-items: center;
justify-content: center;
outline: none;
}
.video-container {
display: block;
}
.video-container video {
vertical-align: top;
display: block;
margin: 0 auto;
}
.flex.items-start.chat-self {
justify-content: flex-end;
}
.chat-self .max-w-\[75\%\].flex.flex-col.items-start {
align-items: flex-end;
}
.chat-self img.avatar-chat.w-8.h-8.rounded-full.mr-2 {
order: 1;
margin-right: 0 !important;
margin-left: 0.5rem;
}
.chat-self .bg-gray-200.mees {
border-top-left-radius: .75rem;
border-top-right-radius: 0;
background-color: #b2e281!important;
}
/* 自身消息(靠右)的图片样式 */
.chat-self .chat-image {
border-top-right-radius: 0;
border-top-left-radius: 0.75rem; /* 保持和消息气泡一致的左上圆角 */
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
}
/* 自身消息的视频容器及视频元素样式 */
.chat-self .video-container video,
.chat-self .video-container .play-button {
border-top-right-radius: 0; /* 右上直角 */
border-top-left-radius: 0.75rem; /* 左上圆角 */
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
}
/* 修正自身消息气泡与媒体元素的圆角一致性 */
.chat-self .bg-gray-200.mees, /* 自身消息气泡 */
.chat-self .video-container, /* 视频容器外层 */
.chat-self .chat-image {
border-top-right-radius: 0 !important; /* 强制右上直角 */
border-top-left-radius: 0.75rem !important; /* 统一左上圆角 */
}
/* 自身消息(靠右)的视频封面样式 */
.chat-self .video-container .video-cover {
border-top-right-radius: 0 !important; /* 右上直角 */
border-top-left-radius: 0.75rem !important; /* 左上圆角 */
border-bottom-left-radius: 0.75rem !important; /* 保持底部圆角 */
border-bottom-right-radius: 0.75rem !important;
}
/* 左侧消息(他人消息)整体容器 */
.flex.items-start:not(.chat-self) {
/* 关键:让左侧消息占据左侧空间,右侧自动留白 */
margin-right: auto; /* 右侧自动填充,消息气泡靠左 */
max-width: 90%; /* 左侧消息最大宽度 70%(小于 80% 留出右侧空间) */
}
/* 右侧消息(自身消息)整体容器 */
.chat-self {
/* 关键:让右侧消息占据右侧空间,左侧自动留白 */
margin-left: auto; /* 左侧自动填充,消息气泡靠右 */
max-width: 90%; /* 右侧消息最大宽度 70%(对称设置) */
}
/* 消息气泡核心样式(统一调整) */
.bg-gray-200.mees {
max-width: 100% !important; /* 气泡宽度基于外层容器的 70% */
width: auto;
word-wrap: break-word;
/* 移除之前冲突的全局 max-width,改为由外层容器控制 */
}
/* 原限制宽度的容器改为自动适应 */
.max-w-[75%].flex.flex-col.items-start {
max-width: 100% !important; /* 取消 75% 固定限制,让外层消息容器(70%)生效 */
width: fit-content; /* 容器宽度随内容自适应 */
}
/* 极长文本时进一步缩小气泡宽度 */
@media (min-width: 769px) {
.flex.items-start:not(.chat-self),
.chat-self {
max-width: 90%; /* 大屏下可稍宽 */
}
}
/* 超小屏幕(如手机竖屏)极致适配 */
@media (max-width: 480px) {
.flex.items-start:not(.chat-self),
.chat-self {
max-width: 90%; /* 小屏幕放宽到 90% */
}
}
/* 确保头像与消息气泡间距合理 */
.avatar-chat {
width: 25px !important; /* 固定头像宽度,避免影响布局计算 */
height: 25px !important;
}
/* 聊天框样式(新增/修改部分) */
div#chat-box {
touch-action: auto; /* 关键修复:允许内部滚动 */
overscroll-behavior-y: contain; /* 阻止滚动影响页面主体 */
-webkit-overflow-scrolling: touch; /* 移动端优化 */
user-select: text; /* 允许内容选择 */
}
#message-list {
user-select: text; /* 明确消息列表可选择 */
}
/* 禁止非文本区域选择(如头像、时间戳) */
.avatar-chat, .text-xs.text-gray-500, .items-center, .chat-header {
user-select: none;
}
/* 输入框和发送按钮可交互 */
#message-input {
user-select: auto; /* 输入框允许文本选择 */
}
/* 防止聊天框外的区域响应滑动事件(可选,根据布局调整) */
.chat-container {
pointer-events: auto;
}
.meida1 {
display: flex
;
align-items: center;
padding-left: 6px;
position: relative;
background-color: #f7f6f1;
}
.mediaItem {
display: flex
;
justify-content: center;
align-items: center;
box-sizing: border-box;
margin: 0 5px;
cursor: pointer;
pointer-events: auto;
}
i.far.fa-smile {
font-size: 18px;
}
/* 表情面板样式 */
#faceBox {
position: absolute;
background: white;
border: 1px solid rgb(224, 224, 224);
padding: 5px;
margin-top: 4px;
background: #fff;
padding: 2px;
border: 1px #dfe6f6 solid;
border-radius: 10px;
height: 126px;
overflow: auto;
padding: 5px;
width: 232px;
top: 341px;
display: none;
pointer-events: auto;
}
#faceBox img {
width: 25px;
height: 25px;
margin: 2px;
cursor: pointer;
border: 0;
vertical-align: middle;
}
/* 消息状态提示 */
.status-text {
font-size: 0.7rem;
color: #666;
margin-top: 4px;
display: block;
text-align: left;
}
.status-sending {
color: #888;
}
.status-success {
color: green;
}
.status-error {
color: red;
}
.load-more-indicator {
text-align: center;
font-size: 0.75rem;
color: #666;
padding: 8px;
cursor: pointer;
}
.unread-indicator {
position: absolute;
top: 8%;
left: 80%;
transform: translate(-50%, -50%);
background-color: #878787;
color: white;
border-radius: 10px;
padding: 2px 5px;
font-size: 10px;
z-index: 10;
cursor: pointer;
}
</style>
<div class="chat-container">
<div class="chat-box">
<div class="chat-header">
<div>聊天室<span class="beta-icon">beta V3.0</span></div>
<div>
<button id="refresh-button">🔄</button>
<button id="close-button">❎</button>
</div>
</div>
<div id="chat-box" class="main relative">
<div class="load-more-indicator" id="loadMoreIndicator">点击加载更多历史消息</div>
<div id="message-list" style="position: relative;"></div>
<div class="unread-indicator" id="unread-indicator"></div>
<div id="last-seen-marker" class="last-seen hidden">请勿在此聊天室发送广告</div>
</div>
<div class="meida1">
<div class="mediaItem">
<div id="faceBtn" class="face-btn" title="选择表情">
<i class="far fa-smile"></i>
</div>
</div>
</div>
<div class="flex">
<input type="text" id="message-input" placeholder="输入消息">
<button id="send-button" ontouchstart="sendMessage()">发送</button>
</div>
<div class="lightbox" id="lightbox">
<div class="lightbox-content">
<span class="close-lightbox">×</span>
<img src="" alt="放大图片" id="lightbox-image">
</div>
</div>
<div id="faceBox" style="position: absolute; background: white; border: 1px solid #e0e0e0; padding: 5px; display: none;"></div>
</div>
<div class="chat-icon">
<i class="fas fa-paper-plane"></i>
</div>
</div>
<script>
const chatContainer = document.querySelector('.chat-container');
const chatBox = document.querySelector('.chat-box');
const chatIcon = document.querySelector('.chat-icon');
const chatContent = document.getElementById('chat-box');
const messageList = document.getElementById('message-list');
const refreshButton = document.getElementById('refresh-button');
const closeButton = document.getElementById('close-button');
const messageInput = document.getElementById('message-input');
const sendButton = document.getElementById('send-button');
const myUid = '<?php echo $user['uid'];?>';
const facePath = '/plugin/Ty_face/face/arclist/';
let hasUserInteracted = false;
let autoRefreshInterval;
const AUTO_REFRESH_INTERVAL = 5000;
const MESSAGES_PER_PAGE = 30;
let isLoadingMore = false;
let hasMoreMessages = true;
let lastLoadedTimestamp = 0;
let lastSeenTimestamp = parseInt(localStorage.getItem('last_seen_timestamp')) || 0;
// 存储临时消息 { timestamp: DOM Element }
const tempMessages = new Map();
const displayedTimestamps = new Set();
document.addEventListener('visibilitychange', handleVisibilityChange);
function initEmojiPanel() {
const faceBtn = document.getElementById('faceBtn');
const faceBox = document.getElementById('faceBox');
for (let i = 1; i <= 54; i++) {
const img = document.createElement('img');
img.src = `${facePath}${i}.gif`;
img.width = 25;
img.height = 25;
img.style.margin = '2px';
img.style.cursor = 'pointer';
img.dataset.code = i;
img.onclick = function () {
insertEmoji(this.dataset.code);
faceBox.style.display = 'none';
};
faceBox.appendChild(img);
}
faceBtn.addEventListener('click', function (e) {
e.stopPropagation();
faceBox.style.display = faceBox.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', function () {
faceBox.style.display = 'none';
});
}
function insertEmoji(code) {
const cursorPos = messageInput.selectionStart;
messageInput.value = messageInput.value.substr(0, cursorPos) + `[em_${code}]` + messageInput.value.substr(cursorPos);
messageInput.selectionStart = messageInput.selectionEnd = cursorPos + `[em_${code}]`.length;
messageInput.focus();
}
function replaceEmojiCodes(message) {
return message.replace(/\[em_(\d+)\]/g, function (match, code) {
return `<img src="${facePath}${code}.gif" width="25px" border="0">`;
});
}
function formatTime(timestamp) {
const now = new Date();
const messageTime = new Date(timestamp);
// 获取“今天”、“昨天”、“前天”的日期
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const dayBeforeYesterday = new Date(today);
dayBeforeYesterday.setDate(today.getDate() - 2);
// 判断是否为今天、昨天、前天
const isToday = messageTime >= today;
const isYesterday = messageTime >= yesterday && messageTime < today;
const isDayBeforeYesterday = messageTime >= dayBeforeYesterday && messageTime < yesterday;
// 格式化时间部分
const hours = String(messageTime.getHours()).padStart(2, '0');
const minutes = String(messageTime.getMinutes()).padStart(2, '0');
if (now - messageTime < 60000) {
return '刚刚';
} else if (isToday) {
return `${hours}:${minutes}`;
} else if (isYesterday) {
return `昨天 ${hours}:${minutes}`;
} else if (isDayBeforeYesterday) {
return `前天 ${hours}:${minutes}`;
} else {
// 更早的日期显示为“5月7日 23:02”
const month = messageTime.getMonth() + 1; // 月份从0开始
const date = messageTime.getDate();
return `${month}月${date}日 ${hours}:${minutes}`;
}
}
function getLocalMessages() {
return JSON.parse(localStorage.getItem('chat_messages') || '[]');
}
function setLocalMessages(messages) {
localStorage.setItem('chat_messages', JSON.stringify(messages));
}
function showUnreadIndicator(count) {
const unreadEl = document.getElementById('unread-indicator');
if (count > 0) {
unreadEl.textContent = `↑ ${count}条未读`;
unreadEl.style.display = 'block';
unreadEl.onclick = () => {
const firstNewMessage = Array.from(messageList.children).find(el => {
return el.dataset.timestamp && parseInt(el.dataset.timestamp) > lastSeenTimestamp;
});
if (firstNewMessage) {
firstNewMessage.scrollIntoView({ behavior: 'smooth' });
localStorage.removeItem('last_seen_timestamp');
unreadEl.style.display = 'none';
}
};
} else {
unreadEl.style.display = 'none';
}
}
function loadChatRecords(isAutoRefresh = false, isLoadMore = false) {
let lastTimestamp = 0; // ✅ 重置初始值
if (isLoadMore) {
// 加载更多时,取最早的消息时间戳(最旧的一条)
const earliestMessage = messageList.firstElementChild;
if (earliestMessage) {
lastTimestamp = parseInt(earliestMessage.dataset.timestamp);
}
} else {
// 正常加载时,取最新的消息时间戳(最后一条)
const latestMessage = messageList.lastElementChild;
if (latestMessage) {
lastTimestamp = parseInt(latestMessage.dataset.timestamp);
}
}
if (!isLoadMore && messageList.children.length > 0) {
const lastMessage = messageList.lastElementChild;
if (lastMessage && lastMessage.dataset.timestamp) {
lastTimestamp = parseInt(lastMessage.dataset.timestamp);
}
}
const xhr = new XMLHttpRequest();
const url = `/lunbo/chat/load_chat.php?last_timestamp=${lastTimestamp}&limit=${MESSAGES_PER_PAGE}&load_more=${isLoadMore ? 1 : 0}`;
xhr.open('GET', url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
const records = JSON.parse(xhr.responseText);
if (records.length < MESSAGES_PER_PAGE) {
hasMoreMessages = false;
}
// ✅ 只在加载更多时更新 lastLoadedTimestamp 为最早的一条
if (isLoadMore && records.length > 0) {
lastLoadedTimestamp = Date.parse(records[records.length - 1].timestamp); // 最旧的一条
} else if (!isLoadMore && records.length > 0) {
lastLoadedTimestamp = Date.parse(records[0].timestamp); // 最新的一条
}
// 缓存新消息到 localStorage
const localMessages = getLocalMessages();
const newMessages = records.filter(record =>
!localMessages.some(m => m.client_timestamp == record.client_timestamp)
);
setLocalMessages([...newMessages, ...localMessages]);
let newMessageCount = 0;
const scrollTopBefore = chatContent.scrollTop;
const scrollHeightBefore = messageList.scrollHeight;
records.forEach((record) => {
const recordTimestamp = Date.parse(record.timestamp);
if (displayedTimestamps.has(recordTimestamp)) return;
displayedTimestamps.add(recordTimestamp);
// 替换临时消息
if (record.client_timestamp) {
const tempMessageEl = tempMessages.get(record.client_timestamp);
if (tempMessageEl) {
const realMessageEl = createRealMessageElement(record);
tempMessageEl.replaceWith(realMessageEl);
tempMessages.delete(record.client_timestamp);
return;
}
}
// 添加新消息到最前面
if (!document.querySelector(`[data-timestamp="${recordTimestamp}"]`)) {
const realMessageEl = createRealMessageElement(record);
messageList.insertBefore(realMessageEl, messageList.firstChild);
newMessageCount++;
}
});
// 恢复滚动位置
if (isLoadMore) {
const heightDiff = messageList.scrollHeight - scrollHeightBefore;
chatContent.scrollTop = scrollTopBefore + heightDiff;
} else if (!isAutoRefresh) {
chatContent.scrollTop = chatContent.scrollHeight;
}
document.getElementById('loadMoreIndicator').textContent =
hasMoreMessages ? '点击加载更多...' : '已加载所有消息!';
if (!isLoadMore) {
showUnreadIndicator(newMessageCount);
}
} finally {
isLoadingMore = false;
}
}
};
xhr.send();
return new Promise(resolve => {
xhr.onload = resolve;
});
}
function startLongPolling() {
function poll() {
loadChatRecords(true).finally(() => {
setTimeout(poll, 1000);
});
}
poll(); // 启动首次请求
}
function startAutoRefresh() {
stopAutoRefresh(); // 确保没有旧的定时器
startLongPolling(); // 使用长轮询
}
function stopAutoRefresh() {
clearInterval(autoRefreshInterval);
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
if (localStorage.getItem('chatBoxState') === 'open') {
startAutoRefresh();
}
} else {
stopAutoRefresh();
}
}
// 手动刷新逻辑(不清空已有内容)
refreshButton.addEventListener('click', function () {
this.style.transform = 'rotate(360deg)';
this.disabled = true;
const earliestElements = Array.from(messageList.querySelectorAll('[data-timestamp]'));
const earliestTimestamp = earliestElements.length
? Math.min(...earliestElements.map(el => parseInt(el.dataset.timestamp)))
: 0;
const xhr = new XMLHttpRequest();
xhr.open('GET', `/lunbo/chat/load_chat.php?since=${earliestTimestamp}`, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
const records = JSON.parse(xhr.responseText);
records.forEach((record) => {
const recordTimestamp = Date.parse(record.timestamp);
if (!displayedTimestamps.has(recordTimestamp)) {
displayedTimestamps.add(recordTimestamp);
const realMessageEl = createRealMessageElement(record);
messageList.insertBefore(realMessageEl, messageList.firstChild);
}
});
} catch (e) {
console.error("刷新失败", xhr.responseText);
}
}
this.style.transform = 'rotate(0deg)';
this.disabled = false;
};
xhr.send();
});
// 页面加载时恢复缓存消息
window.addEventListener('load', () => {
initEmojiPanel();
loadState();
displayedTimestamps.clear();
const cachedMessages = getLocalMessages();
if (cachedMessages.length > 0) {
// ✅ 插入所有缓存消息
for (let i = 0; i < cachedMessages.length; i++) {
const record = cachedMessages[i];
const recordTimestamp = Date.parse(record.timestamp);
if (!displayedTimestamps.has(recordTimestamp)) {
displayedTimestamps.add(recordTimestamp);
const realMessageEl = createRealMessageElement(record);
messageList.insertBefore(realMessageEl, messageList.firstChild);
}
}
// 设置最后一条已加载消息的时间戳(最旧的一条)
lastLoadedTimestamp = Date.parse(cachedMessages[cachedMessages.length - 1].timestamp);
// 如果缓存数量大于等于一页容量,说明还有更多未加载
hasMoreMessages = cachedMessages.length >= MESSAGES_PER_PAGE;
document.getElementById('loadMoreIndicator').textContent =
hasMoreMessages ? '点击加载更多...' : '已加载所有消息!';
// 自动滚动到底部
chatContent.scrollTop = chatContent.scrollHeight;
// ✅ 自动尝试加载更多缓存的历史消息(可选)
setTimeout(() => {
if (hasMoreMessages && !isLoadingMore) {
isLoadingMore = true;
const indicator = document.getElementById('loadMoreIndicator');
indicator.textContent = '加载中...';
loadChatRecords(false, true).finally(() => {
indicator.textContent = hasMoreMessages ? '点击加载更多...' : '已加载所有消息!';
});
}
}, 1000); // 延迟执行,避免影响首屏体验
} else {
loadChatRecords(); // 没有缓存则正常加载
}
chatContent.scrollTop = chatContent.scrollHeight;
messageList.addEventListener('touchmove', (e) => {
if (messageList.scrollHeight > messageList.clientHeight) {
e.preventDefault();
}
}, { passive: false });
document.getElementById('lightbox').addEventListener('click', (e) => {
if (e.target.classList.contains('lightbox') || e.target.classList.contains('close-lightbox')) {
e.target.closest('.lightbox').style.display = 'none';
}
});
sendButton.addEventListener('pointerup', sendMessage);
messageInput.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
const cursorPos = messageInput.selectionStart;
messageInput.value = [
messageInput.value.slice(0, cursorPos),
'\n',
messageInput.value.slice(cursorPos)
].join('');
messageInput.setSelectionRange(cursorPos + 1, cursorPos + 1);
} else if (e.key === 'Enter') {
e.preventDefault();
sendMessage(e);
}
});
// 禁止自动加载更多
let isAutoLoadingDisabled = true;
// 监听点击事件,触发加载更多
document.getElementById('loadMoreIndicator').addEventListener('click', () => {
const indicator = document.getElementById('loadMoreIndicator');
// ✅ 新增防重复点击判断
if (isLoadingMore || !hasMoreMessages) {
if (!hasMoreMessages) alert('已经没有更多消息了');
return;
}
isLoadingMore = true;
indicator.textContent = '加载中...';
indicator.style.opacity = '0.6';
indicator.style.pointerEvents = 'none';
const minLoadingTime = 800;
const startTime = Date.now();
loadChatRecords(false, true).finally(() => {
const duration = Date.now() - startTime;
setTimeout(() => {
indicator.textContent = hasMoreMessages ? '点击加载更多...' : '已加载所有消息!';
indicator.style.opacity = '1';
indicator.style.pointerEvents = 'auto';
isLoadingMore = false; // ✅ 恢复加载状态
}, Math.max(0, minLoadingTime - duration));
});
});
});
// 消息发送逻辑
let isSending = false;
function sendMessage(e) {
if (isSending) return;
const message = messageInput.value.trim();
if (!message) {
showInputError("请输入内容");
return;
}
if (message.length > 140) {
showInputError("消息长度不能超过140字符");
return;
}
if (/<\/?[\s\S]*?(script|onerror|onload|javascript:)/i.test(message)) {
showInputError("禁止包含危险脚本");
return;
}
if (e) e.preventDefault();
const clientTimestamp = Date.now();
const tempMessage = createTempMessage(message, clientTimestamp);
messageList.appendChild(tempMessage);
tempMessages.set(clientTimestamp, tempMessage);
chatContent.scrollTop = chatContent.scrollHeight;
isSending = true;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/lunbo/chat/save_chat.php', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = function () {
isSending = false;
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.status === 'success') {
messageInput.value = '';
const tempMessageEl = tempMessages.get(res.client_timestamp);
if (tempMessageEl) {
const statusEl = tempMessageEl.querySelector('.status-text');
if (statusEl) {
statusEl.textContent = "发送成功!";
statusEl.className = "status-text status-success";
setTimeout(() => {
if (statusEl) statusEl.style.display = 'none';
}, 1500);
}
const realMessageEl = createRealMessageElement({
uid: myUid,
username: '<?php echo htmlspecialchars($user['username']);?>',
avatar_url: '<?php echo $user['avatar_url'];?>',
message: message,
timestamp: res.timestamp,
gid: <?php echo $user['gid'];?>,
client_timestamp: res.client_timestamp
});
tempMessageEl.replaceWith(realMessageEl);
tempMessages.delete(res.client_timestamp);
}
} else {
const statusEl = tempMessage.querySelector('.status-text');
if (statusEl) {
statusEl.textContent = "消息发送失败!";
statusEl.className = "status-text status-error";
setTimeout(() => {
if (statusEl) statusEl.style.display = 'none';
}, 1500);
}
showError("消息发送失败,请重试");
}
} catch (e) {
const statusEl = tempMessage.querySelector('.status-text');
if (statusEl) {
statusEl.textContent = "服务器返回异常";
statusEl.className = "status-text status-error";
setTimeout(() => {
if (statusEl) statusEl.style.display = 'none';
}, 1500);
}
showError("服务器返回异常");
}
} else {
const statusEl = tempMessage.querySelector('.status-text');
if (statusEl) {
statusEl.textContent = "网络请求失败";
statusEl.className = "status-text status-error";
setTimeout(() => {
if (statusEl) statusEl.style.display = 'none';
}, 1500);
}
showError("网络请求失败");
}
};
xhr.onerror = function () {
isSending = false;
const statusEl = tempMessage.querySelector('.status-text');
if (statusEl) {
statusEl.textContent = "网络请求失败";
statusEl.className = "status-text status-error";
setTimeout(() => {
if (statusEl) statusEl.style.display = 'none';
}, 1500);
}
showError("网络请求失败");
};
xhr.send(`message=${encodeURIComponent(message)}&uid=${myUid}&username=<?php echo htmlspecialchars($user['username']);?>&avatar_url=<?php echo $user['avatar_url'];?>&gid=<?php echo $user['gid'];?>&client_timestamp=${clientTimestamp}`);
}
function createTempMessage(message, clientTimestamp) {
const messageElement = document.createElement('div');
messageElement.classList.add('mb-2', 'flex', 'items-start', 'chat-self');
messageElement.dataset.timestamp = Date.now();
messageElement.dataset.clientTimestamp = clientTimestamp;
const currentUsername = '<?php echo htmlspecialchars($user['username']);?>';
const currentAvatar = '<?php echo htmlspecialchars($user['avatar_url']);?>';
const currentUid = myUid;
const currentGid = parseInt('<?php echo $user['gid'];?>', 10);
let usernameColor = "#868e96";
if (currentGid === 1 || currentGid === 106) {
usernameColor = "#c000ff";
}
let messageWithBr = message.replace(/\n/g, '<br>');
messageWithBr = replaceEmojiCodes(messageWithBr);
messageWithBr = messageWithBr.replace(
/(?<!["'])(https?:\/\/[^\s]+\.(?:jpg|jpeg|svg|png|gif|bmp|webp)(?:\?[^\s]*)?)(?!["'])/gi,
'<img data-src="$1" src="$1" alt="图片" class="chat-image" style="max-width: 6rem; height: auto;" onerror="this.src=\'/img/wutu.svg\';">'
);
messageWithBr = messageWithBr.replace(
/(?<!["'])(https?:\/\/(www\.)?[^\s]+\.[^\s]+)(?!["'])(?!\.(?:jpg|jpeg|svg|png|gif|bmp|webp)(?:\?[^\s]*)?)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
);
// ✅ 开始:创建昵称链接(代替原来的 innerHTML)
const avatar = document.createElement('img');
avatar.className = 'avatar-chat w-8 h-8 rounded-full mr-2';
avatar.src = currentAvatar;
avatar.alt = '头像';
avatar.dataset.username = currentUsername;
const wrapper = document.createElement('div');
wrapper.className = 'flex items-start chat-self';
wrapper.dataset.timestamp = Date.now();
const messageContainer = document.createElement('div');
messageContainer.className = 'max-w-[75%] flex flex-col items-start';
// 昵称链接
const metaLine = document.createElement('div');
metaLine.className = 'flex items-center';
const usernameLink = document.createElement('a');
usernameLink.href = `/user-${currentUid}.htm`;
usernameLink.className = 'text-xs text-blue-600 font-semibold mb-1 no-underline';
const usernameSpan = document.createElement('span');
usernameSpan.textContent = currentUsername;
usernameSpan.style.color = usernameColor;
usernameLink.appendChild(usernameSpan);
const uidText = document.createElement('span');
uidText.textContent = `(UID: ${currentUid})`;
uidText.style.fontSize = '10px';
metaLine.appendChild(usernameLink);
metaLine.appendChild(uidText);
const timeText = document.createElement('span');
timeText.className = 'text-xs text-gray-500';
timeText.textContent = '刚刚';
const contentDiv = document.createElement('div');
contentDiv.className = 'bg-gray-200 mees';
contentDiv.innerHTML = messageWithBr;
const statusText = document.createElement('span');
statusText.className = 'status-text status-sending';
statusText.id = `status-${clientTimestamp}`;
statusText.textContent = '发送中...';
messageContainer.appendChild(metaLine);
messageContainer.appendChild(timeText);
messageContainer.appendChild(contentDiv);
messageContainer.appendChild(statusText);
wrapper.appendChild(avatar);
wrapper.appendChild(messageContainer);
return wrapper;
}
function createRealMessageElement(record) {
const recordTimestamp = Date.parse(record.timestamp);
let usernameColor = "#868e96";
if (record.gid === 1 || record.gid === 106) {
usernameColor = "#c000ff";
}
let messageWithBr = record.message.replace(/\n/g, '<br>');
messageWithBr = replaceEmojiCodes(messageWithBr);
messageWithBr = messageWithBr.replace(
/(?<!["'])(https?:\/\/[^\s]+\.(?:jpg|jpeg|svg|png|gif|bmp|webp)(?:\?[^\s]*)?)(?!["'])/gi,
'<img data-src="$1" src="$1" alt="图片" class="chat-image" style="max-width: 6rem; height: auto;" onerror="this.src=\'/img/wutu.svg\';">'
);
messageWithBr = messageWithBr.replace(
/(?<!["'])(https?:\/\/(www\.)?[^\s]+\.[^\s]+)(?!["'])(?!\.(?:jpg|jpeg|svg|png|gif|bmp|webp)(?:\?[^\s]*)?)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
);
const safeUsername = DOMPurify.sanitize(record.username, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: {}
});
// 创建整体消息容器
const messageElement = document.createElement('div');
messageElement.classList.add('mb-2', 'flex', 'items-start');
if (record.uid == myUid) messageElement.classList.add('chat-self');
messageElement.dataset.timestamp = recordTimestamp;
// 头像部分
const avatar = document.createElement('img');
avatar.className = 'avatar-chat w-8 h-8 rounded-full mr-2';
avatar.src = record.avatar_url;
avatar.alt = '头像';
avatar.dataset.username = safeUsername;
// 昵称链接部分
const messageContainer = document.createElement('div');
messageContainer.className = 'max-w-[75%] flex flex-col items-start';
const metaLine = document.createElement('div');
metaLine.className = 'flex items-center';
const usernameLink = document.createElement('a');
usernameLink.href = `/user-${record.uid}.htm`;
usernameLink.className = 'text-xs text-blue-600 font-semibold mb-1 no-underline';
const usernameSpan = document.createElement('span');
usernameSpan.textContent = safeUsername;
usernameSpan.style.color = usernameColor;
usernameLink.appendChild(usernameSpan);
const uidText = document.createElement('span');
uidText.textContent = `(UID: ${record.uid})`;
uidText.style.fontSize = '10px';
metaLine.appendChild(usernameLink);
metaLine.appendChild(uidText);
// 时间部分
const timeText = document.createElement('span');
timeText.className = 'text-xs text-gray-500';
timeText.textContent = formatTime(recordTimestamp);
// 消息内容部分
const contentDiv = document.createElement('div');
contentDiv.className = 'bg-gray-200 mees';
contentDiv.innerHTML = messageWithBr;
// 组装消息内容
messageContainer.appendChild(metaLine);
messageContainer.appendChild(timeText);
messageContainer.appendChild(contentDiv);
// 把头像 + 内容组合起来
messageElement.appendChild(avatar);
messageElement.appendChild(messageContainer);
// 点击 @ 用户名插入输入框
avatar.addEventListener('click', () => {
messageInput.value += `@${safeUsername} `;
messageInput.focus();
});
// 图片点击放大
const chatImages = messageElement.querySelectorAll('.chat-image');
chatImages.forEach(img => {
img.addEventListener('click', () => {
showLightbox(img.dataset.src || img.src);
});
});
return messageElement;
}
function replaceEmojiCodes(message) {
return message.replace(/\[em_(\d+)\]/g, function (match, code) {
return `<img src="${facePath}${code}.gif" width="25px" border="0">`;
});
}
function showInputError(msg) {
messageInput.classList.add('border-red-500');
messageInput.placeholder = msg;
setTimeout(() => {
messageInput.classList.remove('border-red-500');
messageInput.placeholder = "输入消息";
}, 1500);
}
function showError(msg) {
const errorDiv = document.createElement('div');
errorDiv.classList.add('text-red-500', 'text-xs', 'mt-1', 'italic', 'w-full');
errorDiv.textContent = msg;
messageList.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 3000);
}
function loadState() {
const state = localStorage.getItem('chatBoxState');
if (state === 'open') {
chatBox.style.display = 'block';
chatIcon.style.display = 'none';
startAutoRefresh();
} else {
chatBox.style.display = 'none';
chatIcon.style.display = 'flex';
stopAutoRefresh();
}
}
function saveState(state) {
localStorage.setItem('chatBoxState', state);
}
chatIcon.addEventListener('click', () => {
chatBox.style.display = 'block';
chatIcon.style.display = 'none';
saveState('open');
displayedTimestamps.clear();
messageList.innerHTML = '';
lastSeenTimestamp = 0;
localStorage.removeItem('last_seen_timestamp');
loadChatRecords();
startAutoRefresh();
});
closeButton.addEventListener('click', () => {
localStorage.setItem('last_seen_timestamp', lastLoadedTimestamp);
chatBox.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => {
chatBox.style.display = 'none';
chatBox.style.animation = '';
chatIcon.style.display = 'flex';
saveState('closed');
stopAutoRefresh();
}, 300);
});
function showLightbox(imageUrl) {
const lightbox = document.getElementById('lightbox');
const lightboxImage = document.getElementById('lightbox-image');
lightboxImage.src = imageUrl;
lightbox.style.display = 'flex';
}
</script>
<?php endif; ?>
你可以简单粗暴把这堆前端垃圾放到 </footer> 前面,然后下面是后端部分:
如果你不想改动前端的文件引用位置,那么可以在你的网站根目录创建文件夹 /lunbo/chat/,两个后端文件都在这里面调用的,如果你没有放在这里目录里,记得修改前端文件的地址。
文件 load_chat.php
<?php
header('Content-Type: application/json');
ini_set('display_errors', 0);
$servername = "localhost";
$username = "";
$password = "";
$dbname = "";
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->connect_error) {
echo json_encode([]);
exit;
}
$lastTimestamp = isset($_GET['last_timestamp']) ? intval($_GET['last_timestamp']) : 0;
$sinceTimestamp = isset($_GET['since']) ? intval($_GET['since']) : 0;
$loadMore = isset($_GET['load_more']) ? intval($_GET['load_more']) : 0;
$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 30;
$sql = "SELECT * FROM chat_records WHERE timestamp >= ?";
$params = [];
$types = 's';
$thirtyDaysAgo = date('Y-m-d H:i:s', strtotime('-30 days'));
$params[] = $thirtyDaysAgo;
if ($sinceTimestamp > 0) {)
$sql .= " AND timestamp > ?";
$params[] = date('Y-m-d H:i:s', $sinceTimestamp / 1000);
$types .= 's';
} elseif ($loadMore && $lastTimestamp > 0) {
$sql .= " AND timestamp < ?";
$params[] = date('Y-m-d H:i:s', $lastTimestamp / 1000);
$types .= 's';
}
$sql .= " ORDER BY timestamp DESC LIMIT ?";
$params[] = $limit;
$types .= 'i';
$startTime = time();
$maxWaitTime = 25;
do {
$stmt = $conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$newRecords = [];
while ($row = $result->fetch_assoc()) {
$row['username'] = htmlspecialchars($row['username'], ENT_QUOTES, 'UTF-8');
$row['message'] = htmlspecialchars($row['message'], ENT_QUOTES, 'UTF-8');
$newRecords[] = $row;
}
if (!empty($newRecords)) {
echo json_encode($newRecords);
exit;
}
usleep(500000);
} while (time() - $startTime < $maxWaitTime);
echo json_encode([]);
$conn->close();
?>
文件 save_chat.php
<?php
header('Content-Type: application/json');
$servername = "localhost";
$username = "";
$password = "";
$dbname = "";
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->connect_error) {
echo json_encode(['status' => 'error', 'message' => '数据库连接失败']);
exit;
}
$message = isset($_POST['message']) ? trim($_POST['message']) : '';
$uid = isset($_POST['uid']) ? intval($_POST['uid']) : 0;
$username = trim($_POST['username']);
$avatar_url = filter_var(trim($_POST['avatar_url']), FILTER_SANITIZE_URL);
$gid = isset($_POST['gid']) ? intval($_POST['gid']) : 0;
$client_timestamp = isset($_POST['client_timestamp']) ? intval($_POST['client_timestamp']) : 0;
$timestamp = date('Y-m-d H:i:s');
// 白名单标签
$allowedTags = '<br><a><img>';
$message = strip_tags($message, $allowedTags);
// 安全验证
if (preg_match('/<script|javascript:/i', $message)) {
echo json_encode(['status' => 'error', 'message' => '消息包含危险脚本']);
exit;
}
if (mb_strlen($message, 'UTF-8') > 1000) {
echo json_encode(['status' => 'error', 'message' => '消息长度超过限制']);
exit;
}
if ($uid === 0) {
echo json_encode(['status' => 'error', 'message' => '用户ID无效']);
exit;
}
$sql = "INSERT INTO chat_records (uid, username, avatar_url, message, timestamp, gid, client_timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param("issssss", $uid, $username, $avatar_url, $message, $timestamp, $gid, $client_timestamp);
if ($stmt->execute()) {
echo json_encode([
'status' => 'success',
'client_timestamp' => $client_timestamp,
'timestamp' => $timestamp,
'insert_id' => $stmt->insert_id
]);
} else {
echo json_encode(['status' => 'error', 'message' => '写入数据库失败']);
}
$conn->close();
?>
记得编辑两个文件的数据库位置。
创建数据库
创建一个数据库,执行 SQL
CREATE TABLE chat_records (
id INT AUTO_INCREMENT PRIMARY KEY,
uid INT NOT NULL,
username VARCHAR(255) NOT NULL,
avatar_url VARCHAR(255),
message TEXT NOT NULL,
timestamp DATETIME NOT NULL,
gid INT
);
完毕。
简单说明:
发送图片:直接发送图片链接
发送超链接:直接发送带有协议头的链接
艾特别人:点击头像
支持未读消息记录
支持加载历史消息(默认显示 30 条)
添加了管理组和会员用户组的彩色昵称,这里用的判断用户组 ID 的形式,请根据需求修改 GID
忘了说,表情功能是调用/plugin/Ty_face/face/arclist/目录下的表情,与表情插件无关,只是单纯的调用图片而已。你可以修改为你的目录。
本站资源均为作者提供和网友推荐收集整理而来,仅供学习和研究使用,请在下载后24小时内删除,谢谢合作!
THE END