Files
mce/templates/MetaLab-2026/html/posts/show.html
Victor_Jay 99d16190a8 fix: UID 类型混用统一(uint ↔ string)
- Comment.ReplyToUID string→uint, 移除 varchar(36) gorm 约束
- CommentMention.UID string→uint, 移除 varchar(36) gorm 约束
- UserSearchResult.UID string→uint
- 移除 SearchUsersByLevel 中 strconv.FormatUint 转换
- 删除 GetUserUIDByID 方法,CreateReply 直接使用 parent.UserID
- fillReplyToNames 使用 map[uint]string 替代 map[string]string
- 更新 commentStore 接口声明
- 修复前端 mention 下拉 show.html 中 u.id→u.uid
2026-06-02 19:26:44 +08:00

1123 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="container post-detail">
<article class="post-article">
<header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta">
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
{{end}}
{{if .Post.IsLocked}}
<span class="post-status post-status-locked">已锁定</span>
{{end}}
{{if and .Post.PendingBody (eq $.Post.UserID $.UID)}}
<span class="post-status post-status-revision">修订审核中</span>
{{end}}
</div>
</header>
{{if .Post.RejectReason}}
<div class="post-reject-reason">
<strong>退回理由:</strong>{{.Post.RejectReason}}
</div>
{{end}}
<div class="post-detail-body" id="postContent"></div>
</article>
<!-- 赞/踩/收藏按钮 -->
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}">
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
<span class="reaction-like-count" id="likesCount">{{.Post.LikesCount}}</span>
</button>
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
</button>
<button class="reaction-btn reaction-fav-btn" id="favBtn" title="收藏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
<span class="reaction-like-count" id="favCount">{{.Post.FavoritesCount}}</span>
</button>
</div>
{{if .IsLoggedIn}}
<div class="post-actions">
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}}
<a href="/studio/write?id={{$.Post.ID}}" class="btn btn-secondary">编辑</a>
{{end}}
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}}
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
{{end}}
{{if and $.Post (ne $.Post.UserID $.UID)}}
<button class="btn btn-primary" id="energizeBtn" data-id="{{$.Post.ID}}">赋能</button>
{{end}}
</div>
<!-- 赋能弹窗 -->
<div class="energize-overlay" id="energizeOverlay" style="display:none">
<div class="energize-modal">
<div class="energize-modal-header">
<h3>为这篇文章注入能量</h3>
<button class="energize-close" id="energizeClose">&times;</button>
</div>
<div class="energize-modal-body">
<p class="energize-hint" id="energizeCapHint" style="display:none">
今日赋能经验奖励已达上限,继续赋能将消耗域能,对方仍会获得激励。
</p>
<div class="energize-options">
<button class="energize-option-btn" id="energizeLight" data-amount="10">
<span class="energize-option-title">轻赋</span>
<span class="energize-option-cost">消耗 1 域能</span>
<span class="energize-option-gain">获得 10 经验</span>
</button>
<button class="energize-option-btn" id="energizeHeavy" data-amount="20">
<span class="energize-option-title">重赋</span>
<span class="energize-option-cost">消耗 2 域能</span>
<span class="energize-option-gain">获得 20 经验</span>
</button>
</div>
</div>
</div>
</div>
<!-- 选择收藏夹弹窗 -->
<div class="fav-overlay" id="favOverlay" style="display:none">
<div class="fav-modal">
<div class="fav-modal-header">
<h3>选择收藏夹</h3>
<button class="fav-close" id="favClose">&times;</button>
</div>
<div class="fav-modal-body">
<div class="fav-folder-list" id="favFolderCheckList">
<div class="fav-loading">加载中...</div>
</div>
<div class="fav-new-folder">
<input type="text" class="fav-new-input" id="favNewInput" placeholder="新建收藏夹..." maxlength="50">
<button class="fav-new-btn" id="favNewConfirm">创建并收藏</button>
</div>
</div>
</div>
</div>
{{end}}
<!-- 评论区 -->
{{if .Post.AllowComment}}
<section class="comments-section" id="comments">
<h3 class="comments-title">评论 <span class="comments-count" id="commentsTotal">0</span></h3>
{{if .IsLoggedIn}}
<div class="comment-form">
<textarea class="comment-input" id="commentInput" placeholder="写下你的评论..." rows="3" maxlength="5000"></textarea>
<div class="comment-form-actions">
<label class="comment-upload-btn" id="commentUploadLabel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
图片
</label>
<input type="file" id="commentImageInput" accept="image/*" style="display:none" />
<button class="btn btn-primary comment-submit-btn" id="commentSubmitBtn">发表评论</button>
</div>
<div class="comment-upload-preview" id="commentUploadPreview"></div>
</div>
{{else}}
<div class="comment-login-hint">
<a href="/auth/login?redirect=/posts/{{$.Post.ID}}">登录</a> 后参与评论
</div>
{{end}}
<div class="comments-list" id="commentsList"></div>
<div class="comments-loading" id="commentsLoading" style="display:none">加载中...</div>
<button class="btn btn-secondary comments-load-more" id="commentsLoadMore" style="display:none">加载更多</button>
</section>
{{end}}
<!-- @ 提及下拉 -->
<div class="mention-dropdown" id="mentionDropdown" style="display:none"></div>
</div>
{{template "layout/footer.html" .}}
{{/* MD 内容安全传递给 JS */}}
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/method.min.js?v={{assetV "/static/vditor/dist/method.min.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
<script src="/static/js/shortcode.js?v={{assetV "/static/js/shortcode.js"}}" nonce="{{.CSPNonce}}"></script>
<script nonce="{{.CSPNonce}}">
(function() {
var el = document.getElementById('postContent');
var mdEl = document.getElementById('postMdContent');
if (!el || !mdEl) return;
var md = mdEl.value;
Vditor.preview(el, md, {
cdn: '/static/vditor',
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
after: function() {
// 外部链接自动加 nofollow + target="_blank",避免权重流失
var host = window.location.host;
var links = el.querySelectorAll('a[href^="http"]');
for (var i = 0; i < links.length; i++) {
var a = links[i];
if (a.host && a.host !== host) {
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
}
}
}
});
// Vditor.preview() 渲染 MDshortcode 占位 div 原样保留
// 渲染完成后,将占位 div 替换为对应卡片 UI
renderShortcodes(el);
})();
</script>
<script nonce="{{.CSPNonce}}">
// 提交审核按钮
(function() {
var btn = document.getElementById('submitAuditBtn');
if (!btn) return;
btn.addEventListener('click', function() {
if (!confirm('确认提交审核?审核通过后将公开发布。')) return;
fetch('/api/posts/' + btn.dataset.id + '/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { window.location.reload(); }
else { alert(d.message || '提交失败'); }
})
.catch(function() { alert('请求失败'); });
});
})();
</script>
<script nonce="{{.CSPNonce}}">
// 赋能按钮 + 弹窗
(function() {
var btn = document.getElementById('energizeBtn');
var overlay = document.getElementById('energizeOverlay');
var closeBtn = document.getElementById('energizeClose');
var capHint = document.getElementById('energizeCapHint');
var lightBtn = document.getElementById('energizeLight');
var heavyBtn = document.getElementById('energizeHeavy');
if (!btn || !overlay) return;
var postId = btn.dataset.id;
function showOverlay() {
overlay.style.display = 'flex';
}
function hideOverlay() {
overlay.style.display = 'none';
capHint.style.display = 'none';
lightBtn.disabled = false;
heavyBtn.disabled = false;
}
btn.addEventListener('click', function() {
// 先获取域能状态,判断是否需要显示上限提示
fetch('/api/energy/info')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.data && d.data.daily_exp_cap_reached) {
capHint.style.display = 'block';
}
showOverlay();
})
.catch(function() { showOverlay(); });
});
closeBtn.addEventListener('click', hideOverlay);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) hideOverlay();
});
function doEnergize(amount) {
lightBtn.disabled = true;
heavyBtn.disabled = true;
fetch('/api/energy/energize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({ post_id: parseInt(postId), amount: amount })
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
alert(d.data.message || '赋能成功!');
hideOverlay();
} else {
alert(d.message || '赋能失败');
lightBtn.disabled = false;
heavyBtn.disabled = false;
}
})
.catch(function() {
alert('请求失败');
lightBtn.disabled = false;
heavyBtn.disabled = false;
});
}
lightBtn.addEventListener('click', function() {
doEnergize(parseInt(lightBtn.dataset.amount));
});
heavyBtn.addEventListener('click', function() {
doEnergize(parseInt(heavyBtn.dataset.amount));
});
})();
</script>
<script nonce="{{.CSPNonce}}">
// ========== 赞/踩系统 ==========
(function() {
var container = document.getElementById('postReactions');
var likeBtn = document.getElementById('likeBtn');
var dislikeBtn = document.getElementById('dislikeBtn');
var likesCountEl = document.getElementById('likesCount');
if (!container || !likeBtn || !dislikeBtn) return;
var postId = container.dataset.postId;
// 页面加载时获取当前反应状态
fetch('/api/posts/' + postId + '/reaction')
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success || !d.data) return;
updateUI(d.data.reaction, d.data.likes_count);
})
.catch(function() {});
function updateUI(reaction, count) {
likeBtn.classList.remove('active');
dislikeBtn.classList.remove('active');
if (reaction === 'liked') likeBtn.classList.add('active');
if (reaction === 'disliked') dislikeBtn.classList.add('active');
if (typeof count === 'number') likesCountEl.textContent = count;
}
likeBtn.addEventListener('click', function() {
likeBtn.disabled = true;
fetch('/api/posts/' + postId + '/like', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() }
})
.then(function(r) {
if (r.status === 401) { window.location.href = '/auth/login?redirect=/posts/' + postId; return null; }
return r.json();
})
.then(function(d) {
likeBtn.disabled = false;
if (!d) return;
if (d.success) {
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
} else {
alert(d.message || '操作失败');
}
})
.catch(function() { likeBtn.disabled = false; });
});
dislikeBtn.addEventListener('click', function() {
dislikeBtn.disabled = true;
fetch('/api/posts/' + postId + '/dislike', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() }
})
.then(function(r) {
if (r.status === 401) { window.location.href = '/auth/login?redirect=/posts/' + postId; return null; }
return r.json();
})
.then(function(d) {
dislikeBtn.disabled = false;
if (!d) return;
if (d.success) {
updateUI(d.data.disliked ? 'disliked' : 'none');
} else {
alert(d.message || '操作失败');
}
})
.catch(function() { dislikeBtn.disabled = false; });
});
})();
</script>
<script nonce="{{.CSPNonce}}">
// ========== 收藏按钮(弹窗选择收藏夹)==========
(function() {
var favBtn = document.getElementById('favBtn');
var favCountEl = document.getElementById('favCount');
var container = document.getElementById('postReactions');
var overlay = document.getElementById('favOverlay');
var closeBtn = document.getElementById('favClose');
var folderListEl = document.getElementById('favFolderCheckList');
var newInput = document.getElementById('favNewInput');
var newBtn = document.getElementById('favNewConfirm');
if (!favBtn || !container) return;
var postId = container.dataset.postId;
var isFavorited = false;
var currentFolderId = null;
var loading = false;
function api(method, url, data) {
var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
var token = getCSRFToken();
if (token) opts.headers['X-CSRF-Token'] = token;
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(function(r) {
if (r.status === 401) { window.location.href = '/auth/login?redirect=/posts/' + postId; throw new Error('unauthorized'); }
return r.json();
});
}
// 更新按钮状态
function updateBtn(favorited) {
isFavorited = favorited;
if (favorited) {
favBtn.classList.add('active');
} else {
favBtn.classList.remove('active');
currentFolderId = null;
}
}
// 加载收藏状态
function loadStatus() {
return fetch('/api/posts/' + postId + '/folder-status')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.data && d.data.favorited) {
currentFolderId = d.data.folder_id;
updateBtn(true);
}
})
.catch(function() {});
}
// HTML 转义
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// 渲染收藏夹列表
function renderFolderList(folders) {
if (!folders || folders.length === 0) {
folderListEl.innerHTML = '<div class="fav-empty">暂无收藏夹,请在下方创建</div>';
return;
}
var html = '';
folders.forEach(function(f) {
var isActive = currentFolderId === f.id;
html += '<div class="fav-folder-option' + (isActive ? ' active' : '') + '" data-id="' + f.id + '">'
+ '<span class="fav-folder-opt-name">' + esc(f.name) + (f.is_default ? ' <small>(默认)</small>' : '') + '</span>'
+ '<span class="fav-folder-opt-count">' + (f.item_count || 0) + ' 篇</span>'
+ '</div>';
});
folderListEl.innerHTML = html;
}
// 显示弹窗
function showModal() {
if (!overlay) { handleNoModal(); return; }
loading = true;
overlay.style.display = 'flex';
folderListEl.innerHTML = '<div class="fav-loading">加载中...</div>';
api('GET', '/api/folders')
.then(function(d) {
var folders = (d.success && d.data && d.data.folders) ? d.data.folders : [];
renderFolderList(folders);
loading = false;
})
.catch(function() {
folderListEl.innerHTML = '<div class="fav-loading fav-error">加载失败,请重试</div>';
loading = false;
});
}
// 关闭弹窗
function hideModal() {
if (overlay) overlay.style.display = 'none';
loading = false;
}
// 无弹窗回退(未登录跳转)
function handleNoModal() {
if (!getCSRFToken()) {
window.location.href = '/auth/login?redirect=/posts/' + postId;
return;
}
// 无弹窗兜底:直接 toggle
favBtn.disabled = true;
api('POST', '/api/posts/' + postId + '/favorite')
.then(function(d) {
favBtn.disabled = false;
if (d.success) {
var f = d.data && d.data.favorited;
updateBtn(f);
if (favCountEl && d.data && typeof d.data.favorites_count === 'number') {
favCountEl.textContent = d.data.favorites_count;
} else if (favCountEl) {
var cur = parseInt(favCountEl.textContent) || 0;
favCountEl.textContent = f ? cur + 1 : Math.max(0, cur - 1);
}
}
})
.catch(function() { favBtn.disabled = false; });
}
// 收藏到指定收藏夹
function addToFolder(folderId) {
if (loading) return;
loading = true;
api('POST', '/api/folders/' + folderId + '/posts', { post_id: parseInt(postId) })
.then(function(d) {
loading = false;
if (d.success) {
currentFolderId = folderId;
updateBtn(true);
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
hideModal();
} else {
alert(d.message || '操作失败');
}
})
.catch(function() { loading = false; });
}
// 取消收藏
function removeFromFolder(folderId) {
if (loading) return;
if (!confirm('确定取消收藏吗?')) return;
loading = true;
api('DELETE', '/api/folders/' + folderId + '/posts/' + postId)
.then(function(d) {
loading = false;
if (d.success) {
currentFolderId = null;
updateBtn(false);
if (favCountEl) favCountEl.textContent = Math.max(0, (parseInt(favCountEl.textContent) || 0) - 1);
hideModal();
} else {
alert(d.message || '操作失败');
}
})
.catch(function() { loading = false; });
}
// 创建收藏夹并收藏
function createAndAdd(name) {
if (loading) return;
loading = true;
newBtn.disabled = true;
newBtn.textContent = '创建中...';
api('POST', '/api/folders', { name: name, description: '', is_public: false })
.then(function(d) {
if (!d.success) {
loading = false;
newBtn.disabled = false;
newBtn.textContent = '创建并收藏';
alert(d.message || '创建失败');
return;
}
var newId = d.data && d.data.id;
return api('POST', '/api/folders/' + newId + '/posts', { post_id: parseInt(postId) })
.then(function(d2) {
loading = false;
newBtn.disabled = false;
newBtn.textContent = '创建并收藏';
if (d2.success) {
currentFolderId = newId;
updateBtn(true);
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
hideModal();
} else {
alert(d2.message || '操作失败');
}
});
})
.catch(function() {
loading = false;
newBtn.disabled = false;
newBtn.textContent = '创建并收藏';
});
}
// 事件:点击收藏按钮 → 打开弹窗
favBtn.addEventListener('click', function() {
if (!getCSRFToken()) { window.location.href = '/auth/login?redirect=/posts/' + postId; return; }
showModal();
});
// 事件:关闭弹窗
if (closeBtn) closeBtn.addEventListener('click', hideModal);
if (overlay) overlay.addEventListener('click', function(e) {
if (e.target === overlay) hideModal();
});
// 事件:点击收藏夹选项
if (folderListEl) {
folderListEl.addEventListener('click', function(e) {
var option = e.target.closest('.fav-folder-option');
if (!option) return;
var folderId = parseInt(option.getAttribute('data-id'));
if (isNaN(folderId)) return;
if (currentFolderId === folderId) {
removeFromFolder(folderId);
} else {
addToFolder(folderId);
}
});
}
// 事件:新建收藏夹
if (newBtn && newInput) {
newBtn.addEventListener('click', function() {
var name = newInput.value.trim();
if (!name) { alert('请输入收藏夹名称'); return; }
if (name.length > 50) { alert('收藏夹名称不能超过 50 个字符'); return; }
createAndAdd(name);
});
newInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') newBtn.click();
});
}
// 初始化
loadStatus();
})();
</script>
<script nonce="{{.CSPNonce}}">
// ========== 评论系统 ==========
(function() {
var commentsSection = document.getElementById('comments');
if (!commentsSection) return;
var postId = '{{.Post.ID}}';
var commentsList = document.getElementById('commentsList');
var commentsTotal = document.getElementById('commentsTotal');
var loadMoreBtn = document.getElementById('commentsLoadMore');
var commentInput = document.getElementById('commentInput');
var commentSubmitBtn = document.getElementById('commentSubmitBtn');
var mentionDropdown = document.getElementById('mentionDropdown');
var currentPage = 1;
var totalPages = 1;
// ====== 工具函数 ======
function api(method, url, data) {
var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
var token = getCSRFToken();
if (token) opts.headers['X-CSRF-Token'] = token;
if (data) opts.body = JSON.stringify(data);
return fetch(url, opts).then(function(r) { return r.json(); });
}
function escapeHtml(text) {
var d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function formatTime(iso) {
var d = new Date(iso);
var pad = function(n) { return n < 10 ? '0'+n : n; };
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
// ====== 渲染评论 HTML ======
function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || '';
// 渲染 @ 提及为链接
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' +
'<span class="comment-author">' + author + '</span>' +
'<span class="comment-time">' + time + '</span>' +
'</div>' +
'<div class="comment-body">' + body + '</div>' +
'<div class="comment-actions">';
if (isLoggedIn()) {
html += '<button class="comment-action-btn comment-reply-btn" data-id="'+c.id+'">回复</button>';
}
html += '</div>' +
// 回复区域
'<div class="comment-replies" id="replies-' + c.id + '"></div>';
if (c.replies_count > 0) {
html += '<button class="comment-toggle-replies" id="toggle-' + c.id + '" data-id="'+c.id+'" data-loaded="false">共 ' + c.replies_count + ' 条回复</button>';
}
// 回复输入框(默认隐藏)
html += '<div class="comment-reply-form" id="replyForm-' + c.id + '" style="display:none">' +
'<textarea class="comment-reply-input" id="replyInput-' + c.id + '" rows="2" placeholder="写下回复..." maxlength="5000"></textarea>' +
'<button class="btn btn-primary comment-reply-submit" data-id="' + c.id + '">回复</button>' +
'</div>' +
'</div>';
return html;
}
function buildReplyCard(r) {
var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
var prefix = '';
if (r.reply_to_name) {
prefix = '<span class="comment-reply-to">回复 ' + escapeHtml(r.reply_to_name) + ':</span>';
}
return '<div class="comment-reply-card" data-id="' + r.id + '">' +
'<div class="comment-header">' +
'<span class="comment-author" style="font-size:13px">' + author + '</span>' +
'<span class="comment-time" style="font-size:11px">' + time + '</span>' +
'</div>' +
'<div class="comment-reply-body">' + prefix + body + '</div>' +
'</div>';
}
function isLoggedIn() {
return !!document.getElementById('commentInput');
}
// ====== 加载根评论 ======
function loadComments(page) {
var url = '/api/posts/' + postId + '/comments?page=' + page + '&page_size=20';
fetch(url).then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success) return;
totalPages = d.data.total_pages || 1;
commentsTotal.textContent = '(' + d.data.total + ')';
var items = d.data.items || [];
for (var i = 0; i < items.length; i++) {
commentsList.insertAdjacentHTML('beforeend', buildCommentCard(items[i]));
}
if (page < totalPages) {
loadMoreBtn.style.display = 'block';
loadMoreBtn.textContent = '加载更多';
} else {
loadMoreBtn.style.display = 'none';
}
bindEvents();
})
.catch(function() {
commentsList.innerHTML = '<div class="empty-state">评论加载失败</div>';
});
}
// ====== 绑定事件 ======
function bindEvents() {
// 回复按钮
var replyBtns = commentsList.querySelectorAll('.comment-reply-btn');
for (var i = 0; i < replyBtns.length; i++) {
if (replyBtns[i]._bound) continue;
replyBtns[i]._bound = true;
bindReplyBtn(replyBtns[i]);
}
// 展开回复按钮
var toggleBtns = commentsList.querySelectorAll('.comment-toggle-replies');
for (var j = 0; j < toggleBtns.length; j++) {
if (toggleBtns[j]._bound) continue;
toggleBtns[j]._bound = true;
bindToggleBtn(toggleBtns[j]);
}
// 回复提交按钮
var submitBtns = commentsList.querySelectorAll('.comment-reply-submit');
for (var k = 0; k < submitBtns.length; k++) {
if (submitBtns[k]._bound) continue;
submitBtns[k]._bound = true;
bindReplySubmit(submitBtns[k]);
}
// 回复输入框 @提及
var replyInputs = commentsList.querySelectorAll('.comment-reply-input');
for (var m = 0; m < replyInputs.length; m++) {
if (replyInputs[m]._mentionBound) continue;
replyInputs[m]._mentionBound = true;
bindMention(replyInputs[m]);
}
}
function bindReplyBtn(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
var form = document.getElementById('replyForm-' + id);
if (form) {
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
if (form.style.display !== 'none') {
document.getElementById('replyInput-' + id).focus();
}
}
});
}
function bindToggleBtn(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
var loaded = btn.dataset.loaded === 'true';
if (loaded) {
// 切换显示/隐藏
var replies = document.getElementById('replies-' + id);
if (replies) {
replies.style.display = replies.style.display === 'none' ? 'block' : 'none';
btn.textContent = replies.style.display === 'none' ? '共 ' + (btn.dataset.count || replies.children.length) + ' 条回复' : '收起回复';
}
return;
}
// 首次加载
btn.dataset.loaded = 'true';
btn.textContent = '加载中...';
fetch('/api/posts/' + postId + '/comments/' + id + '/replies')
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success) return;
var replies = d.data.replies || [];
var container = document.getElementById('replies-' + id);
if (!container) return;
var html = '';
for (var i = 0; i < replies.length; i++) {
html += buildReplyCard(replies[i]);
}
container.innerHTML = html;
btn.dataset.count = replies.length;
btn.textContent = '收起回复';
})
.catch(function() {
btn.textContent = '加载失败';
});
});
}
function bindReplySubmit(btn) {
btn.addEventListener('click', function() {
var id = btn.dataset.id;
var input = document.getElementById('replyInput-' + id);
if (!input) return;
var body = input.value.trim();
if (!body) { alert('请输入回复内容'); return; }
btn.disabled = true;
api('POST', '/api/comments/' + id + '/reply', { body: body })
.then(function(d) {
btn.disabled = false;
if (d.success) {
input.value = '';
document.getElementById('replyForm-' + id).style.display = 'none';
// 刷新回复列表
var toggleBtn = document.getElementById('toggle-' + id);
if (toggleBtn) {
toggleBtn.dataset.loaded = 'false';
if (toggleBtn.dataset.count) {
toggleBtn.dataset.count = parseInt(toggleBtn.dataset.count) + 1;
toggleBtn.textContent = '共 ' + toggleBtn.dataset.count + ' 条回复';
}
toggleBtn.click();
} else {
// 没有展开按钮,新建一个
var replies = document.getElementById('replies-' + id);
if (replies) {
fetch('/api/posts/' + postId + '/comments/' + id + '/replies')
.then(function(r) { return r.json(); })
.then(function(d2) {
if (!d2.success) return;
var items = d2.data.replies || [];
var html = '';
for (var i = 0; i < items.length; i++) {
html += buildReplyCard(items[i]);
}
replies.innerHTML = html;
});
}
}
updateTotalCount(1);
} else {
alert(d.message || '回复失败');
}
})
.catch(function() {
btn.disabled = false;
alert('请求失败');
});
});
}
// ====== @提及自动补全 ======
var mentionTarget = null;
var mentionPos = 0;
var searchTimer = null;
function bindMention(input) {
input.addEventListener('input', function() {
var val = input.value;
var cursor = input.selectionStart;
// 查找光标前的最后一个 @
var atPos = val.lastIndexOf('@', cursor - 1);
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention();
return;
}
var query = val.substring(atPos + 1, cursor);
if (query.length === 0 || query.includes(' ')) {
hideMention();
return;
}
mentionTarget = input;
mentionPos = atPos;
clearTimeout(searchTimer);
searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query);
} else {
hideMention();
}
})
.catch(function() { hideMention(); });
}, 200);
});
input.addEventListener('blur', function() {
setTimeout(hideMention, 150);
});
}
function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
dropdown.style.display = 'block';
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.width = Math.max(rect.width, 200) + 'px';
for (let i = 0; i < users.length; i++) {
let u = users[i];
var item = document.createElement('div');
item.className = 'mention-item';
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
}
}
function hideMention() {
mentionDropdown.style.display = 'none';
mentionDropdown.innerHTML = '';
mentionTarget = null;
}
function selectMention(user) {
if (!mentionTarget) return;
var val = mentionTarget.value;
var before = val.substring(0, mentionPos);
var after = val.substring(mentionTarget.selectionStart);
var insert = '@' + user.username + ' ';
mentionTarget.value = before + insert + after;
mentionTarget.focus();
var newPos = mentionPos + insert.length;
mentionTarget.setSelectionRange(newPos, newPos);
hideMention();
}
// ====== 发表评论 ======
if (commentSubmitBtn) {
commentSubmitBtn.addEventListener('click', function() {
var body = commentInput.value.trim();
if (!body) { alert('请输入评论内容'); return; }
commentSubmitBtn.disabled = true;
api('POST', '/api/posts/' + postId + '/comments', { body: body })
.then(function(d) {
commentSubmitBtn.disabled = false;
if (d.success) {
commentInput.value = '';
// 刷新列表
commentsList.innerHTML = '';
currentPage = 1;
loadComments(currentPage);
updateTotalCount(1);
} else {
alert(d.message || '发表失败');
}
})
.catch(function() {
commentSubmitBtn.disabled = false;
alert('请求失败');
});
});
// 主输入框 @提及
bindMention(commentInput);
}
// ====== 加载更多 ======
loadMoreBtn.addEventListener('click', function() {
currentPage++;
loadComments(currentPage);
});
function updateTotalCount(delta) {
var el = commentsTotal;
var current = parseInt(el.textContent.replace(/[()]/g, '')) || 0;
el.textContent = '(' + (current + delta) + ')';
}
// ====== 图片上传 ======
var imageUploadInput = document.getElementById('commentImageInput');
var uploadLabel = document.getElementById('commentUploadLabel');
var uploadPreview = document.getElementById('commentUploadPreview');
if (imageUploadInput && uploadLabel) {
uploadLabel.addEventListener('click', function() {
imageUploadInput.click();
});
imageUploadInput.addEventListener('change', function() {
var file = imageUploadInput.files[0];
if (!file) return;
uploadLabel.style.pointerEvents = 'none';
uploadLabel.style.opacity = '0.5';
uploadLabel.textContent = '上传中...';
var formData = new FormData();
formData.append('file', file);
fetch('/api/comments/upload-image', {
method: 'POST',
headers: { 'X-CSRF-Token': getCSRFToken() },
body: formData
})
.then(function(r) { return r.json(); })
.then(function(d) {
uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1';
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
if (d.success && d.data && d.data.url) {
// 插入图片 URL 到文本框当前光标位置
var imgUrl = '\n![](' + d.data.url + ')\n';
var input = commentInput;
var start = input.selectionStart;
var end = input.selectionEnd;
input.value = input.value.substring(0, start) + imgUrl + input.value.substring(end);
input.focus();
input.selectionStart = input.selectionEnd = start + imgUrl.length;
} else {
alert(d.message || '上传失败');
}
})
.catch(function() {
uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1';
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
alert('上传失败');
});
imageUploadInput.value = '';
});
}
// ====== 评论高亮 + 已删除评论Toast ======
function showToast(msg) {
var toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = msg;
toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#fff;padding:10px 24px;border-radius:6px;font-size:14px;z-index:9999;animation:fadeIn 0.3s ease';
document.body.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function() { toast.remove(); }, 300);
}, 2500);
}
function highlightComment(commentId) {
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
if (card) {
// 置顶:移动卡片到列表顶部
commentsList.insertBefore(card, commentsList.firstChild);
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000);
return true;
}
return false;
}
// 解析 hash支持 #comment-{id}、#comments 以及回退)
var hash = window.location.hash;
var commentMatch = hash.match(/^#comment-(\d+)$/);
if (commentMatch) {
var targetCommentId = commentMatch[1];
// 等待评论加载完成后查找
var checkInterval = setInterval(function() {
if (highlightComment(targetCommentId)) {
clearInterval(checkInterval);
}
}, 300);
// 3秒后超时评论可能已被删除
setTimeout(function() {
clearInterval(checkInterval);
if (!commentsList.querySelector('.comment-card[data-id="' + targetCommentId + '"]') &&
!commentsList.querySelector('.comment-reply-card[data-id="' + targetCommentId + '"]')) {
showToast('该评论已不存在');
}
}, 3000);
} else if (hash === '#comments') {
setTimeout(function() {
var section = document.getElementById('comments');
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 400);
}
// ====== 初始化 ======
loadComments(currentPage);
})();
</script>
</body>
</html>