/** * 用户反馈模态弹窗模块 * 供聊天室工具栏"反馈"按钮使用,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 = '
加载中…
'; $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 = '
💬暂无反馈
'; $loader?.classList.add('hidden'); return; } renderFeedbackList(items); updateLoader(); }) .catch(() => { $list.innerHTML = '
😵数据加载失败
'; $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 += ``; // 删除按钮 if (item.can_delete) { actionsHtml += ``; } // 展开评论数 const repliesLabel = item.replies_count > 0 ? `💬 ${item.replies_count}` : '💬 0'; return `
${escapeHtml(item.type_label)} ${escapeHtml(item.status_label)} ${repliesLabel}
${escapeHtml(item.title)}
${escapeHtml(item.username)} · ${escapeHtml(item.created_at)}
`; } function buildReplyHtml(reply) { const adminClass = reply.is_admin ? 'admin' : ''; const badgeHtml = reply.is_admin ? '开发者' : ''; return `
${escapeHtml(reply.username)} ${badgeHtml} ${escapeHtml(reply.created_at)}
${nl2br(escapeHtml(reply.content))}
`; } 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 ? '👆' : '👍') + ' ' + (wasVoted ? prevCount - 1 : prevCount + 1) + ''; 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 ? '👍' : '👆') + ' ' + res.votes_count + ''; if (res.voted) { voteBtn.classList.add('voted'); } else { voteBtn.classList.remove('voted'); } } else { // 回滚 voteBtn.classList.toggle('voted'); voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' ' + prevCount + ''; showToast('❌ ' + (res.message || '操作失败'), 'error'); } }) .catch(() => { // 回滚 voteBtn.classList.toggle('voted'); voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' ' + prevCount + ''; }); } // ── 提交评论 ── 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 = '
💬暂无反馈
'; } } 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, '
'); }