新增聊天室发送图片功能

This commit is contained in:
2026-04-12 14:04:18 +08:00
parent d2f08eb2dd
commit 00b9396dea
10 changed files with 547 additions and 42 deletions
+19 -1
View File
@@ -115,7 +115,8 @@
claimEnvelopeUrl: (id, ceremonyId) => `/wedding/${id}/claim`,
envelopeStatusUrl: (id) => `/wedding/${id}/envelope-status`,
},
earnRewardUrl: "{{ route('earn.video_reward') }}"
earnRewardUrl: "{{ route('earn.video_reward') }}",
chatImageRetentionDays: 3
};
</script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
@@ -174,6 +175,23 @@
@include('chat.partials.global-dialog')
{{-- Toast 轻提示 --}}
@include('chat.partials.toast-notification')
{{-- 聊天图片大图预览层 --}}
<div id="chat-image-lightbox"
style="display:none; position:fixed; inset:0; z-index:10020; background:rgba(15,23,42,.86); backdrop-filter:blur(4px);"
onclick="closeChatImageLightbox(event)">
<div
style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding:32px;"
onclick="closeChatImageLightbox(event)">
<img id="chat-image-lightbox-img" src="" alt="聊天图片预览"
onclick="event.stopPropagation()"
style="max-width:92vw; max-height:86vh; border-radius:12px; box-shadow:0 18px 50px rgba(0,0,0,.45);">
</div>
<button type="button" onclick="closeChatImageLightbox(event)"
style="position:absolute; top:20px; right:24px; z-index:10021; border:none; background:transparent; color:#fff; font-size:34px; cursor:pointer;">&times;</button>
<div id="chat-image-lightbox-name"
style="position:absolute; left:50%; bottom:24px; transform:translateX(-50%); z-index:10021; max-width:88vw; color:#e2e8f0; font-size:12px; text-align:center; word-break:break-all;">
</div>
</div>
{{-- 大卡片通知(任命公告、好友通知、礼包选择等) --}}
@include('chat.partials.chat-banner')
@@ -8,7 +8,7 @@
--}}
<div class="input-bar">
<form id="chat-form" onsubmit="sendMessage(event)">
<form id="chat-form" onsubmit="sendMessage(event)" enctype="multipart/form-data">
{{-- 第一行:工具选项 --}}
<div class="input-row">
<label>
@@ -81,18 +81,18 @@
</button>
<div id="welcome-menu" class="welcome-menu" style="display:none;">
@php
$welcomeMessages = [
'欢迎【{name}】来到我们的聊天室,请遵守规则,文明聊天!',
'【{name}】,你好!欢迎来访,有什么问题随时告诉我们!',
'热烈欢迎【{name}】加入,愿您在这里度过愉快的时光!',
'欢迎新朋友【{name}】!请先阅读公告,了解聊天室规则哦~',
'【{name}】来了!欢迎欢迎,希望你在这里玩得开心!',
'亲爱的【{name}】,欢迎光临本聊天室,请保持文明礼貌!',
'欢迎【{name}】入驻!有问题请联系管理员,我们随时为您服务!',
'【{name}】,初来乍到,欢迎多多关照,我们是一家人!',
'大家欢迎新成员【{name}】!请遵守群规,共建和谐聊天环境!',
'欢迎【{name}】莅临指导!希望你常来,让我们一起聊天!',
];
$welcomeMessages = [
'欢迎【{name}】来到我们的聊天室,请遵守规则,文明聊天!',
'【{name}】,你好!欢迎来访,有什么问题随时告诉我们!',
'热烈欢迎【{name}】加入,愿您在这里度过愉快的时光!',
'欢迎新朋友【{name}】!请先阅读公告,了解聊天室规则哦~',
'【{name}】来了!欢迎欢迎,希望你在这里玩得开心!',
'亲爱的【{name}】,欢迎光临本聊天室,请保持文明礼貌!',
'欢迎【{name}】入驻!有问题请联系管理员,我们随时为您服务!',
'【{name}】,初来乍到,欢迎多多关照,我们是一家人!',
'大家欢迎新成员【{name}】!请遵守群规,共建和谐聊天环境!',
'欢迎【{name}】莅临指导!希望你常来,让我们一起聊天!',
];
@endphp
@foreach ($welcomeMessages as $msg)
<div class="welcome-menu-item" onclick="sendWelcomeTpl({{ json_encode($msg) }})">
@@ -102,8 +102,9 @@
</div>
@if (
$user->user_level >= (int) \App\Models\Sysparam::getValue('level_announcement', '10') ||
$room->master == $user->username)
$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
@@ -112,6 +113,13 @@
style="font-size: 11px; padding: 1px 6px; background: #2563eb; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🎣
钓鱼</button>
<input type="file" id="chat_image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;"
onchange="handleChatImageSelected(this)">
<button type="button" onclick="document.getElementById('chat_image')?.click()"
style="font-size: 11px; padding: 3px 8px; background: #0f766e; color: #fff; border: none; border-radius: 3px; cursor: pointer;">
📷 图片
</button>
@if ($user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100'))
<button type="button" onclick="promptAnnounceMessage()"
style="font-size: 11px; padding: 1px 6px; background: #7c3aed; color: #fff; border: none; border-radius: 2px; cursor: pointer;">📢
@@ -147,8 +155,10 @@
{{-- 第二行:输入框 + 发送 --}}
<div class="input-row">
<input type="text" id="content" name="content" class="say-input"
placeholder="在这里输入聊天内容,按 Enter 发送..." autocomplete="off">
placeholder="在这里输入聊天内容或发送图片,按 Enter 发送..." autocomplete="off">
<button type="submit" id="send-btn" class="send-btn">发送</button>
</div>
</form>
+178 -7
View File
@@ -151,6 +151,80 @@
window.showVipPresenceBanner = showVipPresenceBanner;
/**
* 判断图片消息是否已经超过前端允许展示的保留期。
*/
function isExpiredChatImageMessage(msg) {
if (!msg) {
return false;
}
if (msg.message_type === 'expired_image') {
return true;
}
if (msg.message_type !== 'image') {
return false;
}
if (!msg.image_url || !msg.image_thumb_url) {
return true;
}
const retentionDays = parseInt(window.chatContext?.chatImageRetentionDays || 3, 10);
const sentAtText = String(msg.sent_at || '').replace(' ', 'T');
const sentAt = sentAtText ? new Date(sentAtText) : null;
if (!sentAt || Number.isNaN(sentAt.getTime())) {
return false;
}
return Date.now() >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000;
}
/**
* 构建普通聊天消息的正文区域,支持缩略图与过期占位渲染。
*/
function buildChatMessageContent(msg, fontColor) {
const rawContent = msg.content || '';
if (msg.message_type === 'image' && !isExpiredChatImageMessage(msg)) {
const fullUrl = escapeHtml(msg.image_url || '');
const thumbUrl = escapeHtml(msg.image_thumb_url || '');
const imageName = escapeHtml(msg.image_original_name || '聊天图片');
const captionHtml = rawContent ?
`<span style="display:inline-block; max-width:220px; color:${fontColor}; line-height:1.55;">${rawContent}</span>` :
'';
return `
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}"
onclick="openChatImageLightbox(this.dataset.full, this.dataset.alt); return false;"
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
<img src="${thumbUrl}" alt="${imageName}"
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
</a>
${captionHtml}
</span>
`;
}
if (msg.message_type === 'expired_image' || isExpiredChatImageMessage(msg)) {
const captionHtml = rawContent ?
`<span style="display:inline-block; color:${fontColor}; line-height:1.55;">${rawContent}</span>` :
'';
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
}
return rawContent;
}
// ── Tab 切换 ──────────────────────────────────────
let _roomsRefreshTimer = null;
@@ -606,6 +680,7 @@
}
const headImg =
`<img src="${headImgSrc}" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
const messageBodyHtml = buildChatMessageContent(msg, fontColor);
let html = '';
@@ -726,7 +801,7 @@
buildActionStr(msg.action, fromHtml, toHtml, '悄悄说') :
`${fromHtml}对${toHtml}悄悄说:`;
html =
`${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content" style="color: ${fontColor}; font-style: italic;">${msg.content}</span>`;
`${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
}
} else if (msg.to_user && msg.to_user !== '大家') {
// 对特定对象说话
@@ -735,14 +810,14 @@
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml) :
`${fromHtml}对${toHtml}说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
} else {
// 对大家说话
const fromHtml = clickableUser(msg.from_user, '#000099');
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, '大家') :
`${fromHtml}对大家说:`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${msg.content}</span>`;
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
}
if (!timeStrOverride) {
@@ -1251,6 +1326,99 @@
let _imeComposing = false;
const _contentInput = document.getElementById('content');
/**
* 更新底部图片选择状态提示。
*/
function updateChatImageSelectionLabel(filename = '') {
const nameEl = document.getElementById('chat-image-name');
if (!nameEl) {
return;
}
nameEl.textContent = filename || '未选择图片';
nameEl.style.color = filename ? '#0f766e' : '#64748b';
}
/**
* 处理聊天图片选择后的前端状态展示。
*/
function handleChatImageSelected(input) {
const file = input?.files?.[0] ?? null;
if (!file) {
updateChatImageSelectionLabel('');
return;
}
updateChatImageSelectionLabel(file.name);
// 用户选择图片后,立即触发自动发送
sendMessage(null);
}
/**
* 清理当前选中的聊天图片。
*/
function clearSelectedChatImage(resetInput = false) {
const imageInput = document.getElementById('chat_image');
if (resetInput && imageInput) {
imageInput.value = '';
}
updateChatImageSelectionLabel('');
}
/**
* 打开聊天图片大图预览层。
*/
function openChatImageLightbox(imageUrl, imageName = '聊天图片') {
const lightbox = document.getElementById('chat-image-lightbox');
const imageEl = document.getElementById('chat-image-lightbox-img');
const nameEl = document.getElementById('chat-image-lightbox-name');
if (!lightbox || !imageEl || !imageUrl) {
return;
}
imageEl.src = imageUrl;
imageEl.alt = imageName;
if (nameEl) {
nameEl.textContent = imageName;
}
lightbox.style.display = 'block';
document.body.style.overflow = 'hidden';
}
/**
* 关闭聊天图片大图预览层。
*/
function closeChatImageLightbox(event = null) {
// 如果是点击事件,且点击的目标不是背景或关闭按钮(比如点击了图片本身且没有阻止冒泡),则不关闭
// 已经在 HTML 中对 img 做了 stopPropagation,此处 event.target !== event.currentTarget 仍是安全的
if (event && event.target !== event.currentTarget) {
return;
}
const lightbox = document.getElementById('chat-image-lightbox');
if (!lightbox) return;
lightbox.style.display = 'none';
const imageEl = document.getElementById('chat-image-lightbox-img');
if (imageEl) {
imageEl.src = '';
}
document.body.style.overflow = '';
}
window.handleChatImageSelected = handleChatImageSelected;
window.openChatImageLightbox = openChatImageLightbox;
window.closeChatImageLightbox = closeChatImageLightbox;
updateChatImageSelectionLabel();
// 中文/日文等 IME 组词开始
_contentInput.addEventListener('compositionstart', () => {
_imeComposing = true;
@@ -1302,9 +1470,11 @@
const formData = new FormData(form);
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const imageInput = document.getElementById('chat_image');
const selectedImage = imageInput?.files?.[0] ?? null;
const content = formData.get('content').trim();
if (!content) {
const content = String(formData.get('content') || '').trim();
if (!content && !selectedImage) {
contentInput.focus();
_isSending = false;
return;
@@ -1312,7 +1482,7 @@
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
const toUser = formData.get('to_user');
if (toUser === 'AI小班长') {
if (toUser === 'AI小班长' && content) {
sendToChatBot(content); // 异步调用,不阻塞全局发送
}
@@ -1320,7 +1490,7 @@
// 当用户输入内容符合暗号格式(4-8位大写字母/数字)时,主动尝试领取
// 不依赖轮询标志,服务端负责校验是否真有活跃箱子及暗号是否匹配
const passcodePattern = /^[A-Z0-9]{4,8}$/;
if (passcodePattern.test(content.trim())) {
if (!selectedImage && passcodePattern.test(content.trim())) {
_isSending = false;
try {
@@ -1385,6 +1555,7 @@
const data = await response.json();
if (response.ok && data.status === 'success') {
contentInput.value = '';
clearSelectedChatImage(true);
contentInput.focus();
} else {
window.chatDialog.alert('发送失败: ' + (data.message || JSON.stringify(data.errors)), '操作失败',