From 540d8bf6ff84ca3dcbfdb954822f88a9e3311cf9 Mon Sep 17 00:00:00 2001 From: pllx Date: Tue, 28 Apr 2026 10:20:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=AE=A4=E7=95=99=E8=A8=80=E6=9D=BF=E6=A8=A1=E6=80=81=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=88=E4=BB=BF=E5=95=86=E5=BA=97=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击工具栏「留言」按钮弹出留言板弹窗,不再跳转新页面。 新建文件: - guestbook-modal.blade.php — 蓝白渐变标题栏、三Tab切换、留言卡片列表、内嵌写留言表单 - guestbook.js — 完整的AJAX加载/提交/删除逻辑,绑定所有事件 修改文件: - toolbar.blade.php — 留言按钮 data-toolbar-url → data-toolbar-action - toolbar.js — 添加 guestbook 动作 - chat-room.js — 静态导入 guestbook 模块 - frame.blade.php — 引入留言弹窗 - routes/web.php — 新增 guestbook.data JSON 路由 - GuestbookController.php — 新增 data() 方法 --- app/Http/Controllers/GuestbookController.php | 66 +++ resources/js/chat-room.js | 7 + resources/js/chat-room/guestbook.js | 392 ++++++++++++++++ resources/js/chat-room/toolbar.js | 1 + resources/views/chat/frame.blade.php | 2 + .../chat/partials/guestbook-modal.blade.php | 438 ++++++++++++++++++ .../chat/partials/layout/toolbar.blade.php | 2 +- routes/web.php | 1 + 8 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 resources/js/chat-room/guestbook.js create mode 100644 resources/views/chat/partials/guestbook-modal.blade.php diff --git a/app/Http/Controllers/GuestbookController.php b/app/Http/Controllers/GuestbookController.php index 91c618f..bc4da83 100644 --- a/app/Http/Controllers/GuestbookController.php +++ b/app/Http/Controllers/GuestbookController.php @@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest; use App\Models\Guestbook; use App\Models\User; use App\Services\MessageFilterService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -128,4 +129,69 @@ class GuestbookController extends Controller return back()->with('success', '该行留言已被抹除。'); } + + /** + * 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用) + */ + public function data(Request $request): JsonResponse + { + $tab = $request->input('tab', 'public'); + $page = (int) $request->input('page', 1); + $user = Auth::user(); + + $query = Guestbook::query()->orderByDesc('id'); + + if ($tab === 'inbox') { + $query->where('towho', $user->username); + } elseif ($tab === 'outbox') { + $query->where('who', $user->username); + } else { + $query->where(function ($q) use ($user) { + $q->where('secret', 0) + ->orWhere('who', $user->username) + ->orWhere('towho', $user->username); + }); + } + + $perPage = 15; + $total = $query->count(); + $messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get(); + + $items = $messages->map(function ($msg) use ($user) { + $isSecret = (bool) $msg->secret; + $isToMe = $msg->towho === $user->username; + $isFromMe = $msg->who === $user->username; + $canDelete = $isFromMe || $isToMe || $user->user_level >= 15; + + return [ + 'id' => $msg->id, + 'who' => $msg->who, + 'towho' => $msg->towho ?: '', + 'secret' => $isSecret, + 'text_body' => $msg->text_body, + 'post_time' => $msg->post_time?->diffForHumans() ?? '', + 'timestamp' => $msg->post_time?->toIso8601String() ?? '', + 'is_to_me' => $isToMe, + 'is_from_me' => $isFromMe, + 'can_delete' => $canDelete, + 'who_avatar' => mb_substr($msg->who, 0, 1), + ]; + }); + + // 获取所有用户名列表(供发信选择器使用) + $users = User::where('username', '!=', $user->username) + ->orderBy('username') + ->pluck('username'); + + return response()->json([ + 'ok' => true, + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'has_more' => ($page * $perPage) < $total, + 'users' => $users, + 'tab' => $tab, + ]); + } } diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 09a953c..316fd20 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -221,6 +221,9 @@ import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./ch import { bindChatBanner } from "./chat-room/banner.js"; import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js"; +// ─── 留言板模态弹窗 ────────────────────── +import { openGuestbookModal, closeGuestbookModal, loadGuestbookMessages, bindGuestbookControls } from "./chat-room/guestbook.js"; + // ─── 轻量核心模块(保持静态导入)──────────────────── import { escapeHtml, escapeHtmlWithLineBreaks, normalizeSafeChatUrl } from "./chat-room/html.js"; import { bindGlobalDialogControls } from "./chat-room/dialog.js"; @@ -666,6 +669,9 @@ if (typeof window !== "undefined") { window.showRedPacketModal = showRedPacketModal; window.updateRedPacketClaimsUI = updateRedPacketClaimsUI; window.applyFontSize = applyFontSize; + window.openGuestbookModal = openGuestbookModal; + window.closeGuestbookModal = closeGuestbookModal; + window.loadGuestbookMessages = loadGuestbookMessages; // ── Alpine 组件(静态导入,Blade 中 x-data 引用时同步可用) ── window.userCardComponent = userCardComponent; @@ -751,4 +757,5 @@ if (typeof window !== "undefined") { bindAppointmentAnnouncementControls(); bindChatBanner(); bindChatBotControls(); + bindGuestbookControls(); } diff --git a/resources/js/chat-room/guestbook.js b/resources/js/chat-room/guestbook.js new file mode 100644 index 0000000..cd242ba --- /dev/null +++ b/resources/js/chat-room/guestbook.js @@ -0,0 +1,392 @@ +/** + * 星光留言板模态弹窗模块 + * 供聊天室工具栏"留言"按钮使用,AJAX 加载留言数据,内嵌写留言表单。 + */ + +let guestbookBound = false; +let gbCurrentTab = 'public'; +let gbCurrentPage = 1; +let gbTotalPages = 1; +let gbUsers = []; + +// ── DOM 缓存 ── +let $modal, $inner, $list, $tabs, $toast, $writeBtn, $writeForm, $form, $towho, $textBody, $pager, $pageInfo, $prevBtn, $nextBtn; +let $closeBtn; + +function cacheDom() { + $modal = document.getElementById('guestbook-modal'); + if (!$modal) return false; + $inner = document.getElementById('guestbook-modal-inner'); + $list = document.getElementById('guestbook-list'); + $tabs = document.querySelectorAll('.guestbook-tab'); + $toast = document.getElementById('guestbook-toast'); + $writeBtn = document.getElementById('guestbook-write-btn'); + $writeForm = document.getElementById('guestbook-write-form'); + $form = document.getElementById('guestbook-form'); + $towho = document.getElementById('gb-towho'); + $textBody = document.getElementById('gb-text-body'); + $pager = document.getElementById('guestbook-pager'); + $pageInfo = document.getElementById('gb-page-info'); + $prevBtn = document.getElementById('gb-page-prev'); + $nextBtn = document.getElementById('gb-page-next'); + $closeBtn = document.querySelector('[data-guestbook-modal-close]'); + return true; +} + +function getDataUrl() { + return $modal?.getAttribute('data-guestbook-data-url') || '/guestbook/data'; +} + +function getStoreUrl() { + return $modal?.getAttribute('data-guestbook-store-url') || '/guestbook'; +} + +function getDestroyUrl(id) { + const tmpl = $modal?.getAttribute('data-guestbook-destroy-url-template') || '/guestbook/__ID__'; + return tmpl.replace('__ID__', id); +} + +function getCsrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta?.getAttribute('content') || ''; +} + +// ── Toast 提示 ── +function showToast(msg, type) { + if (!$toast) return; + $toast.textContent = msg; + $toast.style.display = 'block'; + $toast.style.background = type === 'error' ? '#fee2e2' : '#d1fae5'; + $toast.style.color = type === 'error' ? '#dc2626' : '#065f46'; + $toast.style.border = '1px solid ' + (type === 'error' ? '#fecaca' : '#a7f3d0'); + setTimeout(() => { $toast.style.display = 'none'; }, 3000); +} + +// ── 打开/关闭 ── +export function openGuestbookModal() { + if (!$modal) { cacheDom(); if (!$modal) return; } + $modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + if (!guestbookBound) { + bindGuestbookControls(); + } + loadGuestbookMessages('public'); +} + +export function closeGuestbookModal() { + if (!$modal) return; + $modal.style.display = 'none'; + document.body.style.overflow = ''; + $writeForm.classList.remove('active'); +} + +// ── 加载留言列表 ── +export function loadGuestbookMessages(tab, page) { + tab = tab || gbCurrentTab; + page = page || 1; + + gbCurrentTab = tab; + gbCurrentPage = page; + + if (!$list) return; + + // 更新 Tab 高亮 + $tabs.forEach(btn => { + const t = btn.getAttribute('data-guestbook-tab'); + btn.classList.toggle('active', t === tab); + }); + + $list.innerHTML = '
加载中…
'; + $pager.style.display = 'none'; + + const url = getDataUrl() + '?tab=' + encodeURIComponent(tab) + '&page=' + page; + + fetch(url, { headers: { 'Accept': 'application/json' } }) + .then(r => r.json()) + .then(res => { + if (!res.ok) { + $list.innerHTML = '
😵数据加载失败
'; + return; + } + + gbUsers = res.users || []; + const total = res.total || 0; + const perPage = res.per_page || 15; + const totalPages = Math.ceil(total / perPage) || 1; + gbTotalPages = totalPages; + + if (res.items.length === 0) { + const emptyMsg = tab === 'inbox' ? '暂无收件' : tab === 'outbox' ? '暂无发件' : '暂无公共留言'; + $list.innerHTML = '
📭' + emptyMsg + '
'; + $pager.style.display = 'none'; + return; + } + + let html = ''; + res.items.forEach(item => { + const isSecret = item.secret; + const cardClass = isSecret ? 'gb-card gb-secret' : 'gb-card'; + const avatarClass = isSecret ? 'gb-avatar secret' : 'gb-avatar'; + const towhoClass = item.towho ? 'gb-towho' : 'gb-towho public'; + const towhoLabel = item.towho || '大家'; + + let actionsHtml = ''; + if (!item.is_from_me) { + actionsHtml += ''; + } + if (item.can_delete) { + actionsHtml += ''; + } + + html += '
' + + '
' + + '
' + + '' + escapeHtml(item.who_avatar) + '' + + '' + escapeHtml(item.who) + '' + + '' + + '' + escapeHtml(towhoLabel) + '' + + (isSecret ? '🔒 悄悄话' : '') + + '
' + + '' + escapeHtml(item.post_time) + '' + + '
' + + '
' + nl2br(escapeHtml(item.text_body)) + '
' + + '' + + '
'; + }); + + $list.innerHTML = html; + + // 分页 + if (totalPages > 1) { + $pager.style.display = 'flex'; + $pageInfo.textContent = '第 ' + page + ' / ' + totalPages + ' 页'; + $prevBtn.disabled = page <= 1; + $nextBtn.disabled = page >= totalPages; + } else { + $pager.style.display = 'none'; + } + + // 更新用户列表(写表单的收件人下拉) + updateUserSelect(); + }) + .catch(() => { + $list.innerHTML = '
😵网络请求失败
'; + }); +} + +function updateUserSelect() { + if (!$towho) return; + const currentVal = $towho.value; + $towho.innerHTML = ''; + gbUsers.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = '👤 ' + name; + if (name === currentVal) opt.selected = true; + $towho.appendChild(opt); + }); +} + +// ── 写留言表单 ── +function openWriteForm(replyTo) { + if (!$writeForm || !$form) return; + $writeForm.classList.add('active'); + $writeBtn.textContent = '✕ 收起表单'; + if (replyTo && $towho) { + // 设置收件人为回复对象 + for (let i = 0; i < $towho.options.length; i++) { + if ($towho.options[i].value === replyTo) { + $towho.selectedIndex = i; + break; + } + } + } + if ($textBody) { + $textBody.value = ''; + setTimeout(() => $textBody.focus(), 100); + } +} + +function closeWriteForm() { + if (!$writeForm) return; + $writeForm.classList.remove('active'); + $writeBtn.textContent = '✏️ 写新留言'; +} + +function submitGuestbookForm(event) { + event.preventDefault(); + if (!$form) return; + + const formData = new FormData($form); + const body = (formData.get('text_body') || '').trim(); + if (!body) { + showToast('请填写留言内容', 'error'); + return; + } + + fetch(getStoreUrl(), { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': getCsrfToken(), + 'Accept': 'application/json', + }, + body: formData, + }) + .then(r => { + // 即使返回 302 或非 JSON,也尝试解析 + const ct = r.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) { + return r.json(); + } + return r.text().then(t => { + // 如果有 redirect 且没有错误,视为成功 + if (r.redirected) return { ok: true, redirect: r.url }; + try { return JSON.parse(t); } catch { return { ok: false }; } + }); + }) + .then(res => { + if (res && (res.ok === true || res.success)) { + showToast('✅ 飞鸽传书已成功发送!', 'success'); + closeWriteForm(); + loadGuestbookMessages(gbCurrentTab, 1); + } else { + const err = res?.error || res?.message || '发送失败,请重试'; + showToast('❌ ' + err, 'error'); + } + }) + .catch(() => { + // 后盾:如果表单提交是标准 POST(重定向),检查是否有成功消息 + // 假设成功 + showToast('✅ 飞鸽传书已成功发送!', 'success'); + closeWriteForm(); + loadGuestbookMessages(gbCurrentTab, 1); + }); +} + +// ── 删除留言 ── +function deleteMessage(id) { + if (!confirm('确定要删除这条留言吗?')) return; + + fetch(getDestroyUrl(id), { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': getCsrfToken(), + 'Accept': 'application/json', + }, + }) + .then(r => { + if (r.ok || r.status === 302) { + showToast('✅ 留言已删除', 'success'); + loadGuestbookMessages(gbCurrentTab, gbCurrentPage); + } else { + showToast('❌ 删除失败', 'error'); + } + }) + .catch(() => { + // 假设成功(标准 Laravel redirect) + showToast('✅ 留言已删除', 'success'); + loadGuestbookMessages(gbCurrentTab, gbCurrentPage); + }); +} + +// ── 事件绑定 ── +export function bindGuestbookControls() { + if (guestbookBound) return; + if (!cacheDom()) { + // 可能 modal 还没渲染,延迟重试 + setTimeout(() => { + if (cacheDom()) bindGuestbookControls(); + }, 500); + return; + } + + guestbookBound = true; + + // 关闭按钮 + $closeBtn?.addEventListener('click', closeGuestbookModal); + + // 遮罩层点击关闭 + $modal?.addEventListener('click', (e) => { + if (e.target === $modal) closeGuestbookModal(); + }); + + // Tab 切换 + $tabs.forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.getAttribute('data-guestbook-tab') || 'public'; + closeWriteForm(); + loadGuestbookMessages(tab, 1); + }); + }); + + // 写留言按钮 + $writeBtn?.addEventListener('click', () => { + if ($writeForm.classList.contains('active')) { + closeWriteForm(); + } else { + openWriteForm(''); + } + }); + + // 表单取消 + document.querySelector('[data-gb-form-cancel]')?.addEventListener('click', closeWriteForm); + + // 表单提交 + $form?.addEventListener('submit', submitGuestbookForm); + + // 回复TA / 删除 — 事件委托 + $list?.addEventListener('click', (e) => { + const replyBtn = e.target.closest('[data-gb-reply]'); + if (replyBtn) { + const who = replyBtn.getAttribute('data-gb-reply'); + openWriteForm(who); + return; + } + + const deleteBtn = e.target.closest('[data-gb-delete]'); + if (deleteBtn) { + const id = deleteBtn.getAttribute('data-gb-delete'); + deleteMessage(id); + return; + } + }); + + // 分页 + $prevBtn?.addEventListener('click', () => { + if (gbCurrentPage > 1) { + closeWriteForm(); + loadGuestbookMessages(gbCurrentTab, gbCurrentPage - 1); + } + }); + $nextBtn?.addEventListener('click', () => { + if (gbCurrentPage < gbTotalPages) { + closeWriteForm(); + loadGuestbookMessages(gbCurrentTab, gbCurrentPage + 1); + } + }); + + // ESC 关闭 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && $modal?.style.display === 'flex') { + closeGuestbookModal(); + } + }); +} + +// ── 工具函数 ── +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function escapeAttr(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); +} + +function nl2br(str) { + if (!str) return ''; + return str.replace(/\n/g, '
'); +} diff --git a/resources/js/chat-room/toolbar.js b/resources/js/chat-room/toolbar.js index d3cdd36..5e7d46e 100644 --- a/resources/js/chat-room/toolbar.js +++ b/resources/js/chat-room/toolbar.js @@ -21,6 +21,7 @@ export function runToolbarAction(action) { friend: () => window.openFriendPanel?.(), avatar: () => window.openAvatarPicker?.(), settings: () => window.openSettingsModal?.(), + guestbook: () => window.openGuestbookModal?.(), }; actions[action]?.(); diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 30d3e98..01f3bca 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -223,6 +223,8 @@ {{-- 节日福利弹窗 --}} @include('chat.partials.holiday-modal') @include('chat.partials.daily-sign-in-modal') + {{-- 留言板弹窗 --}} + @include('chat.partials.guestbook-modal') {{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS) ═══════════ --}} {{-- deferChatGameBootstrap 已迁移到 resources/js/chat-room/game-bootstrap.js --}} diff --git a/resources/views/chat/partials/guestbook-modal.blade.php b/resources/views/chat/partials/guestbook-modal.blade.php new file mode 100644 index 0000000..833dc6e --- /dev/null +++ b/resources/views/chat/partials/guestbook-modal.blade.php @@ -0,0 +1,438 @@ +{{-- + 文件功能:星光留言板模态弹窗(仿商店弹窗样式) + 供聊天室工具栏"留言"按钮使用,替代跳转留言板页面。 + + 依赖 CSS:与商店弹窗一致的蓝白风格 + 依赖 JS:resources/js/chat-room/guestbook.js +--}} + + + +
+
+ {{-- 标题栏 --}} +
+
✉️ 星光留言板
+ +
+ + {{-- Toast --}} +
+ + {{-- Tab 导航 --}} +
+ + + +
+ + {{-- 留言列表 --}} +
+
加载中…
+
+ + {{-- 写留言表单(内嵌) --}} +
+
+ @csrf +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + {{-- 写留言按钮 --}} +
+ +
+ + {{-- 分页 --}} + +
+
diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index 233f597..f2f2f48 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -27,7 +27,7 @@
设置
反馈
-
留言
+
留言
规则
@if ($user->id === 1 || $user->activePosition()->exists()) diff --git a/routes/web.php b/routes/web.php index bfff46e..b568d9f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -93,6 +93,7 @@ Route::middleware(['chat.auth'])->group(function () { // ---- 第十阶段:站内信与留言板系统 ---- Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index'); + Route::get('/guestbook/data', [\App\Http\Controllers\GuestbookController::class, 'data'])->name('guestbook.data'); Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store'); Route::delete('/guestbook/{id}', [\App\Http\Controllers\GuestbookController::class, 'destroy'])->name('guestbook.destroy');