From 9c51339559c282037e52e11f678340861aa3c212 Mon Sep 17 00:00:00 2001 From: anghunk Date: Thu, 22 Jan 2026 19:12:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(comments):=20=E5=AE=9E=E7=8E=B0=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E7=82=B9=E8=B5=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在数据库和API中添加likes字段支持 - 新增评论点赞接口和前端交互逻辑 - 在前端展示点赞数并处理点赞操作 - 更新相关组件和存储逻辑以支持点赞功能 --- cwd-admin/src/api/admin.ts | 1 + cwd-admin/src/views/CommentsView.vue | 15 +++++ cwd-api/schemas/comment.sql | 1 + cwd-api/src/api/admin/importComments.ts | 8 ++- cwd-api/src/api/admin/listComments.ts | 1 + cwd-api/src/api/public/getComments.ts | 4 +- cwd-api/src/api/public/likeComment.ts | 64 +++++++++++++++++++ cwd-api/src/index.ts | 2 + docs/widget/src/components/CommentItem.js | 28 +++++++++ docs/widget/src/components/CommentList.js | 9 ++- docs/widget/src/core/CWDComments.js | 8 ++- docs/widget/src/core/api.js | 35 ++++++++++- docs/widget/src/core/store.js | 77 ++++++++++++++++++++++- 13 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 cwd-api/src/api/public/likeComment.ts diff --git a/cwd-admin/src/api/admin.ts b/cwd-admin/src/api/admin.ts index ab15ce0..881ad3e 100644 --- a/cwd-admin/src/api/admin.ts +++ b/cwd-admin/src/api/admin.ts @@ -19,6 +19,7 @@ export type CommentItem = { contentHtml: string; status: string; priority?: number; + likes?: number; ua?: string | null; }; diff --git a/cwd-admin/src/views/CommentsView.vue b/cwd-admin/src/views/CommentsView.vue index 4b95832..b52888e 100644 --- a/cwd-admin/src/views/CommentsView.vue +++ b/cwd-admin/src/views/CommentsView.vue @@ -22,6 +22,7 @@
用户
评论信息
评论地址
+
点赞
状态
操作
@@ -69,6 +70,9 @@ >{{ item.postSlug }} +
+ +
@@ -676,6 +680,12 @@ watch(domainFilter, () => { flex-shrink: 0; } +.table-cell-likes { + width: 80px; + flex-shrink: 0; + justify-content: center; +} + .table-cell-time { width: 150px; flex-shrink: 0; @@ -755,6 +765,11 @@ watch(domainFilter, () => { color: #57606a; } +.cell-likes-number { + font-size: 13px; + color: #57606a; +} + .cell-author-wrapper { display: flex; align-items: flex-start; diff --git a/cwd-api/schemas/comment.sql b/cwd-api/schemas/comment.sql index 7daa04c..0e752db 100644 --- a/cwd-api/schemas/comment.sql +++ b/cwd-api/schemas/comment.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS Comment ( content_text TEXT NOT NULL, content_html TEXT NOT NULL, parent_id INTEGER, + likes INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 1, status TEXT DEFAULT 'approved', -- 建立自引用外键约束(父子评论关系) diff --git a/cwd-api/src/api/admin/importComments.ts b/cwd-api/src/api/admin/importComments.ts index cbaa9fd..bb62418 100644 --- a/cwd-api/src/api/admin/importComments.ts +++ b/cwd-api/src/api/admin/importComments.ts @@ -119,13 +119,14 @@ export const importComments = async (c: Context<{ Bindings: Bindings }>) => { content_text, content_html, parent_id, - status + status, + likes } = comment; const fields = [ 'created', 'post_slug', 'name', 'email', 'url', 'ip_address', 'device', 'os', 'browser', 'ua', - 'content_text', 'content_html', 'parent_id', 'status' + 'content_text', 'content_html', 'parent_id', 'status', 'likes' ]; const values = [ created || Date.now(), @@ -141,7 +142,8 @@ export const importComments = async (c: Context<{ Bindings: Bindings }>) => { content_text || "", content_html || "", parent_id || null, - status || "approved" + status || "approved", + typeof likes === 'number' && Number.isFinite(likes) && likes >= 0 ? likes : 0 ]; if (id !== undefined && id !== null) { diff --git a/cwd-api/src/api/admin/listComments.ts b/cwd-api/src/api/admin/listComments.ts index ff0c566..18f124d 100644 --- a/cwd-api/src/api/admin/listComments.ts +++ b/cwd-api/src/api/admin/listComments.ts @@ -53,6 +53,7 @@ export const listComments = async (c: Context<{ Bindings: Bindings }>) => { contentHtml: row.content_html, status: row.status, priority: row.priority, + likes: typeof row.likes === 'number' && Number.isFinite(row.likes) && row.likes >= 0 ? row.likes : 0, ua: row.ua, avatar: await getCravatar(row.email, avatarPrefix || undefined) })) diff --git a/cwd-api/src/api/public/getComments.ts b/cwd-api/src/api/public/getComments.ts index 451e077..135cb9f 100644 --- a/cwd-api/src/api/public/getComments.ts +++ b/cwd-api/src/api/public/getComments.ts @@ -36,7 +36,7 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => { let query = ` SELECT id, name, email, url, content_text as contentText, content_html as contentHtml, created, parent_id as parentId, - post_slug as postSlug, priority + post_slug as postSlug, priority, COALESCE(likes, 0) as likes FROM Comment WHERE status = "approved" AND post_slug = ? ORDER BY priority DESC, created DESC @@ -46,7 +46,7 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => { query = ` SELECT id, name, email, url, content_text as contentText, content_html as contentHtml, created, parent_id as parentId, - post_slug as postSlug, priority + post_slug as postSlug, priority, COALESCE(likes, 0) as likes FROM Comment WHERE status = "approved" AND post_slug IN (${placeholders}) ORDER BY priority DESC, created DESC diff --git a/cwd-api/src/api/public/likeComment.ts b/cwd-api/src/api/public/likeComment.ts new file mode 100644 index 0000000..d4dd849 --- /dev/null +++ b/cwd-api/src/api/public/likeComment.ts @@ -0,0 +1,64 @@ +import { Context } from 'hono'; +import { Bindings } from '../../bindings'; + +export const likeComment = async (c: Context<{ Bindings: Bindings }>) => { + let body: any = null; + try { + body = await c.req.json(); + } catch { + body = null; + } + + const rawId = + (body && (body.id ?? body.commentId)) ?? + c.req.query('id') ?? + c.req.query('commentId') ?? + null; + + const parsed = + typeof rawId === 'number' + ? rawId + : typeof rawId === 'string' && rawId.trim() + ? Number.parseInt(rawId.trim(), 10) + : NaN; + + if (!Number.isFinite(parsed) || parsed <= 0) { + return c.json({ message: 'Missing or invalid id' }, 400); + } + + const id = parsed; + + try { + const existing = await c.env.CWD_DB.prepare( + 'SELECT id, likes FROM Comment WHERE id = ?' + ) + .bind(id) + .first<{ id: number; likes?: number }>(); + + if (!existing) { + return c.json({ message: 'Comment not found' }, 404); + } + + await c.env.CWD_DB.prepare( + 'UPDATE Comment SET likes = COALESCE(likes, 0) + 1 WHERE id = ?' + ) + .bind(id) + .run(); + + const updated = await c.env.CWD_DB.prepare( + 'SELECT COALESCE(likes, 0) as likes FROM Comment WHERE id = ?' + ) + .bind(id) + .first<{ likes?: number }>(); + + const likes = + updated && typeof updated.likes === 'number' && Number.isFinite(updated.likes) && updated.likes >= 0 + ? updated.likes + : ((existing.likes || 0) + 1); + + return c.json({ id, likes }); + } catch (e: any) { + return c.json({ message: e?.message || '点赞失败' }, 500); + } +}; + diff --git a/cwd-api/src/index.ts b/cwd-api/src/index.ts index 1c1bcc7..a5cf298 100644 --- a/cwd-api/src/index.ts +++ b/cwd-api/src/index.ts @@ -27,6 +27,7 @@ import { getDomains } from './api/admin/getDomains'; import { trackVisit } from './api/public/trackVisit'; import { getVisitOverview, getVisitPages } from './api/admin/visitAnalytics'; import { getLikeStatus, likePage } from './api/public/like'; +import { likeComment } from './api/public/likeComment'; import { listLikes } from './api/admin/listLikes'; import { getLikeStats } from './api/admin/likeStats'; @@ -223,6 +224,7 @@ app.post('/api/verify-admin', verifyAdminKey); app.post('/api/analytics/visit', trackVisit); app.get('/api/like', getLikeStatus); app.post('/api/like', likePage); +app.post('/api/comments/like', likeComment); app.get('/api/config/comments', async (c) => { try { const settings = await loadCommentSettings(c.env); diff --git a/docs/widget/src/components/CommentItem.js b/docs/widget/src/components/CommentItem.js index 8a1bf4a..47f2352 100644 --- a/docs/widget/src/components/CommentItem.js +++ b/docs/widget/src/components/CommentItem.js @@ -105,6 +105,28 @@ export class CommentItem extends Component { }, text: '回复' }), + this.createElement('span', { + className: 'cwd-comment-like', + children: [ + this.createElement('button', { + className: 'cwd-comment-like-button', + attributes: { + type: 'button', + onClick: () => this.handleLikeComment() + }, + text: '赞' + }), + this.createTextElement( + 'span', + String( + typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0 + ? comment.likes + : 0 + ), + 'cwd-comment-like-count' + ) + ] + }), this.createTextElement('span', formatRelativeTime(comment.created), 'cwd-comment-time') ] }) @@ -259,6 +281,12 @@ export class CommentItem extends Component { } } + handleLikeComment() { + if (this.props.onLikeComment) { + this.props.onLikeComment(this.props.comment.id); + } + } + handleSubmitReply() { if (this.props.onSubmitReply) { this.props.onSubmitReply(this.props.comment.id); diff --git a/docs/widget/src/components/CommentList.js b/docs/widget/src/components/CommentList.js index e8ba6b1..b92e4c6 100644 --- a/docs/widget/src/components/CommentList.js +++ b/docs/widget/src/components/CommentList.js @@ -100,7 +100,8 @@ export class CommentList extends Component { onSubmitReply: (commentId) => this.handleSubmitReply(commentId), onCancelReply: () => this.handleCancelReply(), onUpdateReplyContent: (content) => this.handleUpdateReplyContent(content), - onClearReplyError: () => this.handleClearReplyError() + onClearReplyError: () => this.handleClearReplyError(), + onLikeComment: (commentId) => this.handleLikeComment(commentId) }); commentItem.render(); // 缓存 CommentItem 实例 @@ -219,6 +220,12 @@ export class CommentList extends Component { } } + handleLikeComment(commentId) { + if (this.props.onLikeComment) { + this.props.onLikeComment(commentId); + } + } + handlePrevPage() { if (this.props.onPrevPage) { this.props.onPrevPage(); diff --git a/docs/widget/src/core/CWDComments.js b/docs/widget/src/core/CWDComments.js index 2946549..021ed92 100644 --- a/docs/widget/src/core/CWDComments.js +++ b/docs/widget/src/core/CWDComments.js @@ -176,7 +176,8 @@ export class CWDComments { this.store = createCommentStore( this.config, api.fetchComments.bind(api), - api.submitComment.bind(api) + api.submitComment.bind(api), + typeof api.likeComment === 'function' ? api.likeComment.bind(api) : undefined ); this.unsubscribe = this.store.store.subscribe((state) => { @@ -379,6 +380,11 @@ export class CWDComments { onPrevPage: () => this.store.goToPage(state.pagination.page - 1), onNextPage: () => this.store.goToPage(state.pagination.page + 1), onGoToPage: (page) => this.store.goToPage(page), + onLikeComment: (commentId) => { + if (this.store && typeof this.store.likeComment === 'function') { + this.store.likeComment(commentId); + } + }, }); this.commentList.render(); } diff --git a/docs/widget/src/core/api.js b/docs/widget/src/core/api.js index d43378e..2279170 100644 --- a/docs/widget/src/core/api.js +++ b/docs/widget/src/core/api.js @@ -9,7 +9,7 @@ * @param {string} config.postSlug - 文章标识符 * @param {string} config.postTitle - 文章标题(可选) * @param {string} config.postUrl - 文章 URL(可选) - * @returns {Object} + * @returns {Object} */ export function createApiClient(config) { const baseUrl = config.apiBaseUrl.replace(/\/$/, ''); @@ -178,12 +178,41 @@ export function createApiClient(config) { return response.json(); } - return { + async function likeComment(commentId) { + const id = + typeof commentId === 'number' + ? commentId + : typeof commentId === 'string' && commentId.trim() + ? Number.parseInt(commentId.trim(), 10) + : NaN; + if (!Number.isFinite(id) || id <= 0) { + throw new Error('Invalid comment id'); + } + const response = await fetch(`${baseUrl}/api/comments/like`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ id }) + }); + if (!response.ok) { + let msg = response.statusText; + try { + const json = await response.json(); + if (json.message) msg = json.message; + } catch (e) {} + throw new Error(msg); + } + return response.json(); + } + + return { fetchComments, submitComment, verifyAdminKey, trackVisit, getLikeStatus, - likePage + likePage, + likeComment }; } diff --git a/docs/widget/src/core/store.js b/docs/widget/src/core/store.js index b50c6ea..1e14290 100644 --- a/docs/widget/src/core/store.js +++ b/docs/widget/src/core/store.js @@ -90,9 +90,10 @@ class Store { * @param {Object} config - 配置对象 * @param {Function} fetchComments - 获取评论的函数 * @param {Function} submitComment - 提交评论的函数 + * @param {Function} likeCommentFn - 点赞评论的函数 * @returns {Object} */ -export function createCommentStore(config, fetchComments, submitComment) { +export function createCommentStore(config, fetchComments, submitComment, likeCommentFn) { // 从 localStorage 加载用户信息 const savedInfo = loadUserInfo(); @@ -128,6 +129,7 @@ export function createCommentStore(config, fetchComments, submitComment) { replyError: null, likeCount: 0, liked: false, + commentLikeLoadingId: null, }); // 监听用户信息变化,自动保存到 localStorage @@ -175,6 +177,78 @@ export function createCommentStore(config, fetchComments, submitComment) { }); } + async function likeComment(commentId) { + const state = store.getState(); + if (!likeCommentFn || state.commentLikeLoadingId === commentId) { + return; + } + const id = + typeof commentId === 'number' + ? commentId + : typeof commentId === 'string' && commentId.trim() + ? Number.parseInt(commentId.trim(), 10) + : NaN; + if (!Number.isFinite(id) || id <= 0) { + return; + } + store.setState({ + commentLikeLoadingId: id, + }); + try { + const safeComments = Array.isArray(state.comments) ? state.comments : []; + const nextComments = safeComments.map((item) => { + if (!item || typeof item.id !== 'number') { + return item; + } + if (item.id === id) { + const current = + typeof item.likes === 'number' && Number.isFinite(item.likes) && item.likes >= 0 + ? item.likes + : 0; + return { + ...item, + likes: current + 1, + }; + } + if (Array.isArray(item.replies) && item.replies.length > 0) { + const updatedReplies = item.replies.map((reply) => { + if (!reply || typeof reply.id !== 'number') { + return reply; + } + if (reply.id === id) { + const current = + typeof reply.likes === 'number' && Number.isFinite(reply.likes) && reply.likes >= 0 + ? reply.likes + : 0; + return { + ...reply, + likes: current + 1, + }; + } + return reply; + }); + return { + ...item, + replies: updatedReplies, + }; + } + return item; + }); + store.setState({ + comments: nextComments, + }); + await likeCommentFn(id); + } catch (e) { + } finally { + const latest = store.getState(); + if (latest.commentLikeLoadingId === id) { + store.setState({ + commentLikeLoadingId: null, + }); + } + } + } + /** * 提交评论 */ @@ -395,5 +469,6 @@ export function createCommentStore(config, fetchComments, submitComment) { clearSuccess, goToPage, setLikeState, + likeComment, }; }