feat: 实现赞/踩系统(P2)

- 新增 PostLike/PostDislike 模型,复合主键 (user_id, post_id)
- Post 表新增 LikesCount/DislikesCount 冗余计数器
- Repository 层:reaction_repo.go,含 WithTx 事务支持
- Service 层:reaction_service.go,赞踩互斥逻辑(6 态切换),事务包裹
- Controller 层:reaction_controller.go,3 个 API(ToggleLike/ToggleDislike/GetReaction)
- 接口定义:controller/interfaces.go 新增 reactionUseCase,service/repository.go 新增 ReactionStore
- 路由注册 + 依赖注入 + AutoMigrate
- 前端:文章详情页赞踩按钮 + 状态查询 + 401 跳转登录
- 样式:posts.css 新增赞踩按钮样式
This commit is contained in:
2026-06-01 15:48:18 +08:00
parent 4815060839
commit 2b47380ac5
14 changed files with 557 additions and 2 deletions

View File

@ -31,6 +31,17 @@
<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>
</div>
{{if .IsLoggedIn}}
<div class="post-actions">
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}}
@ -253,6 +264,82 @@
})();
</script>
<script>
// ========== 赞/踩系统 ==========
(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;
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
// 页面加载时获取当前反应状态
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': csrfToken }
})
.then(function(r) {
if (r.status === 401) { window.location.href = '/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': csrfToken }
})
.then(function(r) {
if (r.status === 401) { window.location.href = '/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>
// ========== 评论系统 ==========
(function() {

View File

@ -197,7 +197,72 @@
gap: 12px;
}
/* ========== 404 ========== */
/* ========== 赞/踩按钮 ========== */
.post-reactions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 12px 0;
border-top: 1px solid #e5e7eb;
}
.reaction-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 14px;
color: #6b7280;
transition: all 0.2s;
user-select: none;
}
.reaction-btn:hover {
border-color: #c4b5fd;
color: #6366f1;
background: #f5f3ff;
}
.reaction-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.reaction-btn.active.reaction-like-btn {
border-color: #6366f1;
color: #6366f1;
background: #f0eeff;
}
.reaction-btn.active.reaction-like-btn svg {
fill: #6366f1;
stroke: #6366f1;
}
.reaction-btn.active.reaction-dislike-btn {
border-color: #f87171;
color: #f87171;
background: #fef2f2;
}
.reaction-btn.active.reaction-dislike-btn svg {
fill: #f87171;
stroke: #f87171;
}
.reaction-like-count {
font-weight: 600;
min-width: 12px;
}
.reaction-btn.active .reaction-like-count {
color: inherit;
}
.error-404 {
text-align: center;
padding: 100px 0;