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 @@
@@ -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,
};
}