新增:聊天室反馈模态弹窗(仿留言弹窗样式)

点击工具栏「反馈」按钮弹出反馈弹窗,不再跳转新页面。

新建文件:
- feedback-modal.blade.php — 蓝白渐变标题栏、类型筛选Tabs、反馈卡片列表(展开详情/评论)、提交反馈表单、滚动懒加载
- feedback.js — AJAX加载/提交/点赞/评论/删除,滚动懒加载,乐观UI更新

修改文件:
- toolbar.blade.php — 反馈按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 feedback 动作
- chat-room.js — 静态导入 feedback 模块
- frame.blade.php — 引入反馈弹窗
- routes/web.php — 新增 feedback.data 路由
- FeedbackController.php — 新增 data() 方法
This commit is contained in:
pllx
2026-04-28 10:29:14 +08:00
parent 540d8bf6ff
commit 62371a7c64
8 changed files with 1255 additions and 1 deletions
+9
View File
@@ -224,6 +224,9 @@ import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-
// ─── 留言板模态弹窗 ──────────────────────
import { openGuestbookModal, closeGuestbookModal, loadGuestbookMessages, bindGuestbookControls } from "./chat-room/guestbook.js";
// ─── 反馈模态弹窗 ──────────────────────
import { openFeedbackModal, closeFeedbackModal, loadFeedbackData, loadMoreFeedback, bindFeedbackControls } from "./chat-room/feedback.js";
// ─── 轻量核心模块(保持静态导入)────────────────────
import { escapeHtml, escapeHtmlWithLineBreaks, normalizeSafeChatUrl } from "./chat-room/html.js";
import { bindGlobalDialogControls } from "./chat-room/dialog.js";
@@ -672,6 +675,11 @@ if (typeof window !== "undefined") {
window.openGuestbookModal = openGuestbookModal;
window.closeGuestbookModal = closeGuestbookModal;
window.loadGuestbookMessages = loadGuestbookMessages;
window.openFeedbackModal = openFeedbackModal;
window.closeFeedbackModal = closeFeedbackModal;
window.loadFeedbackData = loadFeedbackData;
window.loadMoreFeedback = loadMoreFeedback;
window.bindFeedbackControls = bindFeedbackControls;
// ── Alpine 组件(静态导入,Blade 中 x-data 引用时同步可用) ──
window.userCardComponent = userCardComponent;
@@ -758,4 +766,5 @@ if (typeof window !== "undefined") {
bindChatBanner();
bindChatBotControls();
bindGuestbookControls();
bindFeedbackControls();
}
+625
View File
@@ -0,0 +1,625 @@
/**
* 用户反馈模态弹窗模块
* 供聊天室工具栏"反馈"按钮使用,AJAX 加载反馈列表,内嵌提交表单。
*/
let feedbackBound = false;
let fbCurrentTab = 'all';
let fbLastId = null;
let fbHasMore = true;
let fbLoading = false;
// ── DOM 缓存 ──
let $modal, $inner, $list, $tabs, $toast, $writeBtn, $writeOverlay, $form, $type, $title, $content;
let $closeBtn, $writeClose, $formCancel, $loader;
function cacheDom() {
$modal = document.getElementById('feedback-modal');
if (!$modal) return false;
$inner = document.getElementById('feedback-modal-inner');
$list = document.getElementById('feedback-list');
$tabs = document.querySelectorAll('.feedback-tab');
$toast = document.getElementById('feedback-toast');
$writeBtn = document.getElementById('feedback-submit-btn');
$writeOverlay = document.getElementById('feedback-write-overlay');
$form = document.getElementById('feedback-form');
$type = document.getElementById('fb-type');
$title = document.getElementById('fb-title');
$content = document.getElementById('fb-content');
$closeBtn = document.querySelector('[data-feedback-modal-close]');
$writeClose = document.querySelector('[data-feedback-write-close]');
$formCancel = document.querySelector('[data-fb-form-cancel]');
$loader = document.getElementById('feedback-loader');
return true;
}
function getDataUrl() {
return $modal?.getAttribute('data-feedback-data-url') || '/feedback/data';
}
function getMoreUrl() {
return $modal?.getAttribute('data-feedback-more-url') || '/feedback/more';
}
function getStoreUrl() {
return $modal?.getAttribute('data-feedback-store-url') || '/feedback';
}
function getVoteUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-vote-url-template') || '/feedback/__ID__/vote';
return tmpl.replace('__ID__', id);
}
function getReplyUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-reply-url-template') || '/feedback/__ID__/reply';
return tmpl.replace('__ID__', id);
}
function getDestroyUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-destroy-url-template') || '/feedback/__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 openFeedbackModal() {
if (!$modal) { cacheDom(); if (!$modal) return; }
$modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!feedbackBound) {
bindFeedbackControls();
}
loadFeedbackData('all');
}
export function closeFeedbackModal() {
if (!$modal) return;
$modal.style.display = 'none';
document.body.style.overflow = '';
$writeOverlay?.classList.remove('active');
}
// ── 加载反馈数据 ──
export function loadFeedbackData(tab) {
tab = tab || fbCurrentTab;
fbCurrentTab = tab;
fbLastId = null;
fbHasMore = true;
fbLoading = false;
if (!$list) return;
// 更新 Tab 高亮
$tabs.forEach(btn => {
const t = btn.getAttribute('data-feedback-tab');
btn.classList.toggle('active', t === tab);
});
$list.innerHTML = '<div class="fb-loading">加载中…</div>';
$loader?.classList.add('hidden');
let url = getDataUrl();
if (tab !== 'all') {
url += '?type=' + encodeURIComponent(tab);
}
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
const items = res.items || [];
fbLastId = res.last_id || (items.length > 0 ? items[items.length - 1].id : null);
fbHasMore = res.has_more === true;
if (items.length === 0) {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">💬</span>暂无反馈</div>';
$loader?.classList.add('hidden');
return;
}
renderFeedbackList(items);
updateLoader();
})
.catch(() => {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">😵</span>数据加载失败</div>';
$loader?.classList.add('hidden');
});
}
// ── 加载更多 ──
export function loadMoreFeedback() {
if (fbLoading || !fbHasMore || !fbLastId) return;
fbLoading = true;
$loader?.classList.remove('hidden');
$loader.textContent = '加载中…';
let url = getMoreUrl() + '?after_id=' + fbLastId;
if (fbCurrentTab !== 'all') {
url += '&type=' + encodeURIComponent(fbCurrentTab);
}
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
const items = res.items || [];
fbLoading = false;
if (items.length > 0) {
fbLastId = items[items.length - 1].id;
fbHasMore = res.has_more === true;
appendFeedbackList(items);
} else {
fbHasMore = false;
}
updateLoader();
})
.catch(() => {
fbLoading = false;
$loader?.classList.add('hidden');
});
}
// ── 渲染列表 ──
function renderFeedbackList(items) {
let html = '';
items.forEach(item => {
html += buildFeedbackCard(item);
});
$list.innerHTML = html;
}
function appendFeedbackList(items) {
items.forEach(item => {
$list.insertAdjacentHTML('beforeend', buildFeedbackCard(item));
});
}
function buildFeedbackCard(item) {
const typeClass = item.type === 'bug' ? 'bug' : 'suggestion';
const statusClass = 'fb-status-badge';
const statusStyle = getStatusStyle(item.status_color);
let actionsHtml = '';
// 赞同按钮(不能赞同自己的)
const voteIcon = item.voted ? '👍' : '👆';
const voteBtnClass = item.voted ? 'fb-vote-btn voted' : 'fb-vote-btn';
const voteDisabled = item.is_owner ? 'disabled' : '';
actionsHtml += `<button class="${voteBtnClass}" data-fb-vote="${item.id}" ${voteDisabled} title="${item.is_owner ? '不能赞同自己的反馈' : '赞同'}">${voteIcon} <span data-fb-vote-count="${item.id}">${item.votes_count}</span></button>`;
// 删除按钮
if (item.can_delete) {
actionsHtml += `<button class="fb-delete-btn" data-fb-delete="${item.id}">删除</button>`;
}
// 展开评论数
const repliesLabel = item.replies_count > 0 ? `💬 ${item.replies_count}` : '💬 0';
return `<div class="fb-card" data-fb-id="${item.id}">
<div class="fb-card-header">
<span class="fb-type-badge ${typeClass}">${escapeHtml(item.type_label)}</span>
<span class="${statusClass}" style="${statusStyle}">${escapeHtml(item.status_label)}</span>
<span class="fb-reply-count">${repliesLabel}</span>
</div>
<div class="fb-card-title">${escapeHtml(item.title)}</div>
<div class="fb-card-meta">${escapeHtml(item.username)} · ${escapeHtml(item.created_at)}</div>
<div class="fb-card-footer-actions">${actionsHtml}</div>
<div class="fb-detail" style="display:none;">
<div class="fb-detail-content">${nl2br(escapeHtml(item.content))}</div>
${item.admin_remark ? `<div class="fb-admin-remark"><div class="fb-admin-remark-label">🛡️ 开发者官方回复</div>${nl2br(escapeHtml(item.admin_remark))}</div>` : ''}
<div class="fb-replies" data-fb-replies="${item.id}">
${(item.replies || []).map(r => buildReplyHtml(r)).join('')}
</div>
<div class="fb-reply-form">
<textarea data-fb-reply-input="${item.id}" placeholder="补充说明…" maxlength="1000" rows="1"></textarea>
<button data-fb-reply-btn="${item.id}">发送</button>
</div>
</div>
</div>`;
}
function buildReplyHtml(reply) {
const adminClass = reply.is_admin ? 'admin' : '';
const badgeHtml = reply.is_admin ? '<span class="fb-reply-admin-badge">开发者</span>' : '';
return `<div class="fb-reply-item ${adminClass}">
<div class="fb-reply-header">
<span class="fb-reply-username">${escapeHtml(reply.username)}</span>
${badgeHtml}
<span class="fb-reply-time">${escapeHtml(reply.created_at)}</span>
</div>
<div class="fb-reply-body">${nl2br(escapeHtml(reply.content))}</div>
</div>`;
}
function getStatusStyle(color) {
const map = {
gray: 'background:#f3f4f6;color:#4b5563;',
green: 'background:#dcfce7;color:#15803d;',
blue: 'background:#dbeafe;color:#1d4ed8;',
emerald: 'background:#d1fae5;color:#047857;',
red: 'background:#fee2e2;color:#dc2626;',
orange: 'background:#ffedd5;color:#c2410c;',
};
return map[color] || 'background:#f3f4f6;color:#4b5563;';
}
function updateLoader() {
if (!$loader) return;
if (fbHasMore) {
$loader.classList.remove('hidden');
$loader.textContent = fbLoading ? '加载中…' : '↓ 下拉加载更多';
} else {
$loader.classList.add('hidden');
}
}
// ── 提交反馈表单 ──
function openWriteForm() {
if (!$writeOverlay) return;
$writeOverlay.classList.add('active');
if ($title) {
$title.value = '';
setTimeout(() => $title.focus(), 100);
}
if ($content) $content.value = '';
if ($type) $type.value = 'bug';
}
function closeWriteForm() {
if (!$writeOverlay) return;
$writeOverlay.classList.remove('active');
}
function submitFeedbackForm(event) {
event.preventDefault();
if (!$form) return;
const title = ($title?.value || '').trim();
const content = ($content?.value || '').trim();
const type = $type?.value || 'bug';
if (!title) {
showToast('请填写标题', 'error');
return;
}
if (!content) {
showToast('请填写详细描述', 'error');
return;
}
const submitBtn = $form.querySelector('.fb-form-submit');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = '提交中…';
}
fetch(getStoreUrl(), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ type, title, content }),
})
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
showToast('✅ ' + (res.message || '反馈已提交,感谢您的贡献!'), 'success');
closeWriteForm();
loadFeedbackData(fbCurrentTab);
} else {
showToast('❌ ' + (res.message || '提交失败,请重试'), 'error');
}
})
.catch(() => {
showToast('❌ 网络异常,请重试', 'error');
})
.finally(() => {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = '✈️ 提交反馈';
}
});
}
// ── 赞同/取消赞同 ──
function toggleVote(id) {
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
const voteBtn = card?.querySelector('[data-fb-vote]');
if (!voteBtn) return;
// 乐观更新
const wasVoted = voteBtn.classList.contains('voted');
const countSpan = voteBtn.querySelector('[data-fb-vote-count]');
let prevCount = parseInt(countSpan?.textContent || '0');
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👆' : '👍') + ' <span data-fb-vote-count="' + id + '">' + (wasVoted ? prevCount - 1 : prevCount + 1) + '</span>';
fetch(getVoteUrl(id), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
voteBtn.innerHTML = (res.voted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + res.votes_count + '</span>';
if (res.voted) {
voteBtn.classList.add('voted');
} else {
voteBtn.classList.remove('voted');
}
} else {
// 回滚
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + prevCount + '</span>';
showToast('❌ ' + (res.message || '操作失败'), 'error');
}
})
.catch(() => {
// 回滚
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + prevCount + '</span>';
});
}
// ── 提交评论 ──
function submitReply(id) {
const input = $list?.querySelector(`[data-fb-reply-input="${id}"]`);
const btn = $list?.querySelector(`[data-fb-reply-btn="${id}"]`);
if (!input || !btn) return;
const content = input.value.trim();
if (!content) return;
btn.disabled = true;
btn.textContent = '发送中…';
fetch(getReplyUrl(id), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ content }),
})
.then(r => r.json())
.then(res => {
if (res.status === 'success' && res.reply) {
// 追加评论到列表
const repliesContainer = $list?.querySelector(`[data-fb-replies="${id}"]`);
if (repliesContainer) {
repliesContainer.insertAdjacentHTML('beforeend', buildReplyHtml(res.reply));
}
input.value = '';
// 更新评论计数
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
if (card) {
const countEl = card.querySelector('.fb-reply-count');
if (countEl) {
const current = parseInt(countEl.textContent?.replace(/[^0-9]/g, '') || '0');
countEl.textContent = '💬 ' + (current + 1);
}
}
showToast('✅ 评论已提交', 'success');
} else {
showToast('❌ ' + (res.message || '评论失败'), 'error');
}
})
.catch(() => {
showToast('❌ 网络异常', 'error');
})
.finally(() => {
btn.disabled = false;
btn.textContent = '发送';
});
}
// ── 删除反馈(通过 AJAX) ──
function deleteFeedback(id) {
if (!confirm('确定要删除这条反馈吗?')) return;
fetch(getDestroyUrl(id), {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => {
if (r.ok) {
return r.json();
}
throw new Error('删除失败');
})
.then(res => {
if (res.status === 'success') {
showToast('✅ ' + (res.message || '反馈已删除'), 'success');
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
if (card) card.remove();
// 检查列表是否为空
if ($list && $list.querySelectorAll('.fb-card').length === 0) {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">💬</span>暂无反馈</div>';
}
} else {
showToast('❌ ' + (res.message || '删除失败'), 'error');
}
})
.catch(() => {
showToast('❌ 删除失败', 'error');
});
}
// ── 展开/收起详情 ──
function toggleDetail(card) {
if (!card) return;
const detail = card.querySelector('.fb-detail');
if (!detail) return;
const isVisible = detail.style.display !== 'none';
// 收起其他已展开的
$list?.querySelectorAll('.fb-card .fb-detail').forEach(d => {
if (d !== detail) d.style.display = 'none';
});
detail.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// 自动调整 textarea 高度
const ta = card.querySelector('.fb-reply-form textarea');
if (ta) {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
}
}
// ── 事件绑定 ──
export function bindFeedbackControls() {
if (feedbackBound) return;
if (!cacheDom()) {
setTimeout(() => {
if (cacheDom()) bindFeedbackControls();
}, 500);
return;
}
feedbackBound = true;
// 关闭按钮
$closeBtn?.addEventListener('click', closeFeedbackModal);
// 遮罩层点击关闭
$modal?.addEventListener('click', (e) => {
if (e.target === $modal) closeFeedbackModal();
});
// Tab 切换
$tabs.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-feedback-tab') || 'all';
closeWriteForm();
loadFeedbackData(tab);
});
});
// 提交反馈按钮(底部)
$writeBtn?.addEventListener('click', openWriteForm);
// 写表单关闭/取消
$writeClose?.addEventListener('click', closeWriteForm);
$formCancel?.addEventListener('click', closeWriteForm);
// 点击写表单遮罩关闭
$writeOverlay?.addEventListener('click', (e) => {
if (e.target === $writeOverlay) closeWriteForm();
});
// 表单提交
$form?.addEventListener('submit', submitFeedbackForm);
// ── 事件委托:列表内部交互 ──
$list?.addEventListener('click', (e) => {
// 卡片点击展开/收起(排除按钮)
const card = e.target.closest('.fb-card');
if (card && !e.target.closest('button') && !e.target.closest('textarea')) {
toggleDetail(card);
return;
}
// 赞同按钮
const voteBtn = e.target.closest('[data-fb-vote]');
if (voteBtn) {
const id = voteBtn.getAttribute('data-fb-vote');
if (!voteBtn.disabled) toggleVote(id);
return;
}
// 删除按钮
const deleteBtn = e.target.closest('[data-fb-delete]');
if (deleteBtn) {
const id = deleteBtn.getAttribute('data-fb-delete');
deleteFeedback(id);
return;
}
// 评论发送按钮
const replyBtn = e.target.closest('[data-fb-reply-btn]');
if (replyBtn) {
const id = replyBtn.getAttribute('data-fb-reply-btn');
if (!replyBtn.disabled) submitReply(id);
return;
}
});
// ── 事件委托:列表滚动加载更多 ──
$list?.addEventListener('scroll', () => {
if (!$list) return;
const { scrollTop, scrollHeight, clientHeight } = $list;
if (scrollTop + clientHeight >= scrollHeight - 60) {
loadMoreFeedback();
}
});
// ── 事件委托:评论输入框自动调整高度 ──
$list?.addEventListener('input', (e) => {
const ta = e.target.closest('[data-fb-reply-input]');
if (ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 80) + 'px';
}
});
// ── 事件委托:评论输入框 Enter 发送 ──
$list?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
const ta = e.target.closest('[data-fb-reply-input]');
if (ta) {
e.preventDefault();
const id = ta.getAttribute('data-fb-reply-input');
submitReply(id);
}
}
});
// ESC 关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if ($writeOverlay?.classList.contains('active')) {
closeWriteForm();
} else if ($modal?.style.display === 'flex') {
closeFeedbackModal();
}
}
});
}
// ── 工具函数 ──
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function nl2br(str) {
if (!str) return '';
return str.replace(/\n/g, '<br>');
}
+1
View File
@@ -22,6 +22,7 @@ export function runToolbarAction(action) {
avatar: () => window.openAvatarPicker?.(),
settings: () => window.openSettingsModal?.(),
guestbook: () => window.openGuestbookModal?.(),
feedback: () => window.openFeedbackModal?.(),
};
actions[action]?.();