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 += ''
+ + ''
+ + '
' + 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 导航 --}}
+
+
+
+
+
+
+ {{-- 留言列表 --}}
+
+
+ {{-- 写留言表单(内嵌) --}}
+
+
+ {{-- 写留言按钮 --}}
+
+
+
+
+ {{-- 分页 --}}
+
+
+
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');