feat(评论): 新增功能开关管理及评论点赞功能优化

- 在管理后台添加功能开关页面,支持控制评论点赞和文章点赞功能的开启/关闭
- 优化评论点赞功能,支持取消点赞和本地存储点赞状态
- 更新点赞UI样式,提升用户体验
- 添加相关API接口和文档说明
This commit is contained in:
anghunk
2026-01-22 22:26:43 +08:00
parent 74c7fa9e1c
commit e0be92b1fe
15 changed files with 806 additions and 120 deletions

View File

@@ -77,6 +77,7 @@ GET /api/comments
-`nested=true`(默认)时,接口返回的是"根评论列表",每条根评论包含其 `replies`
-`nested=false` 时,接口返回扁平列表,所有评论都在 `data` 中,`replies` 为空。
- `priority` 字段:评论的置顶权重,数值越大排序越靠前。
- `likes` 字段:评论的点赞数,默认为 0。
**错误响应**
@@ -439,6 +440,124 @@ POST /api/like
}
```
### 1.5 评论点赞
```
POST /api/comments/like
```
对指定评论进行点赞操作。
- 方法:`POST`
- 路径:`/api/comments/like`
- 鉴权:不需要
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"id": 123
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ---------- |
| `id` | number | 是 | 评论 ID |
**成功响应**
- 状态码:`200`
```json
{
"message": "点赞成功",
"likes": 5
}
```
字段说明:
| 字段名 | 类型 | 说明 |
| ------- | ------ | -------------- |
| `likes` | number | 更新后的点赞数 |
**错误响应**
- 状态码:`400`
```json
{
"message": "评论不存在"
}
```
### 1.6 取消评论点赞
```
DELETE /api/comments/like
```
取消对指定评论的点赞。
- 方法:`DELETE`
- 路径:`/api/comments/like`
- 鉴权:不需要
**请求头**
| 名称 | 必填 | 示例 |
| -------------- | ---- | ------------------ |
| `Content-Type` | 是 | `application/json` |
**请求体**
```json
{
"id": 123
}
```
字段说明:
| 字段名 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ---------- |
| `id` | number | 是 | 评论 ID |
**成功响应**
- 状态码:`200`
```json
{
"message": "取消点赞成功",
"likes": 4
}
```
字段说明:
| 字段名 | 类型 | 说明 |
| ------- | ------ | -------------- |
| `likes` | number | 更新后的点赞数 |
**错误响应**
- 状态码:`400`
```json
{
"message": "评论不存在"
}
```
## 2. 配置相关
### 2.1 获取评论相关的公开配置
@@ -470,14 +589,16 @@ GET /api/config/comments
字段说明:
| 字段名 | 类型 | 说明 |
| -------------- | ------- | -------------------------------------------------------------------- |
| `adminEmail` | string | 博主邮箱地址,用于在前端展示"博主"标识,并触发管理员身份验证流程 |
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
| `adminEnabled` | boolean | 是否启用博主标识相关展示(关闭时不显示徽标,但仍可作为管理员邮箱) |
| `allowedDomains` | Array\<string\> | 允许调用组件的域名列表,留空则不限制 |
| `requireReview` | boolean | 是否开启新评论先审核再显示true 表示新评论默认为待审核) |
| 字段名 | 类型 | 说明 |
| ------------------ | ------- | -------------------------------------------------------------------- |
| `adminEmail` | string | 博主邮箱地址,用于在前端展示"博主"标识,并触发管理员身份验证流程 |
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
| `adminEnabled` | boolean | 是否启用博主标识相关展示(关闭时不显示徽标,但仍可作为管理员邮箱) |
| `allowedDomains` | Array\<string\> | 允许调用组件的域名列表,留空则不限制 |
| `requireReview` | boolean | 是否开启新评论先审核再显示true 表示新评论默认为待审核) |
| `enableCommentLike` | boolean | 是否启用评论点赞功能(默认 true |
| `enableArticleLike` | boolean | 是否启用文章点赞功能(默认 true |
**错误响应**

View File

@@ -7,6 +7,11 @@ import { ReplyEditor } from './ReplyEditor.js';
import { formatRelativeTime } from '@/utils/date.js';
export class CommentItem extends Component {
// 防抖缓存,防止连续点击
static _likeDebounce = new Map();
// 用户标识缓存(单例,确保一致性)
static _userId = null;
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
@@ -105,49 +110,58 @@ export class CommentItem extends Component {
},
text: '回复'
}),
this.createElement('div', {
className: 'cwd-comment-like',
children: [
this.createElement('button', {
className: 'cwd-comment-like-button',
attributes: {
type: 'button',
'aria-label': '点赞',
onClick: () => this.handleLikeComment()
},
children: [
this.createElement('span', {
className: 'cwd-comment-like-icon-wrapper',
children: [
this.createElement('svg', {
className: 'cwd-comment-like-icon',
attributes: {
viewBox: '0 0 24 24',
'aria-hidden': 'true'
},
children: [
this.createElement('path', {
attributes: {
d: 'M12 21c-.4 0-.8-.1-1.1-.4L4.5 15C3 13.6 2 11.7 2 9.6 2 6.5 4.5 4 7.6 4c1.7 0 3.3.8 4.4 2.1C13.1 4.8 14.7 4 16.4 4 19.5 4 22 6.5 22 9.6c0 2.1-1 4-2.5 5.4l-6.4 5.6c-.3.3-.7.4-1.1.4z'
}
})
]
})
]
}),
this.createTextElement(
'span',
String(
typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0
? comment.likes
: 0
),
'cwd-comment-like-count'
)
]
})
]
}),
...(this.props.enableCommentLike !== false ? [
this.createElement('div', {
className: 'cwd-comment-like',
children: [
this.createElement('button', {
className: `cwd-comment-like-button${this.hasLiked(comment.id) ? ' cwd-comment-like-button-liked' : ''}`,
attributes: {
type: 'button',
'aria-label': this.hasLiked(comment.id) ? '取消点赞' : '点赞',
onClick: () => this.handleLikeComment()
},
children: [
this.createElement('span', {
className: 'cwd-comment-like-icon-wrapper',
children: [
this.createElement('svg', {
className: 'cwd-comment-like-icon',
attributes: {
viewBox: '0 0 24 24',
'aria-hidden': 'true',
fill: this.hasLiked(comment.id) ? 'currentColor' : 'none'
},
children: [
this.createElement('path', {
attributes: {
d: 'M2 21h4V9H2v12zm20-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L13 1 7.59 6.41C7.22 6.78 7 7.3 7 7.83V19c0 1.1.9 2 2 2h8c.78 0 1.48-.45 1.82-1.11l3.02-7.05c.11-.23.16-.48.16-.74v-2z'
}
})
]
})
]
}),
...(() => {
const likeCount =
typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0
? comment.likes
: 0;
return likeCount >= 1
? [
this.createTextElement(
'span',
String(likeCount),
'cwd-comment-like-count'
)
]
: [];
})()
]
})
]
})
] : []),
this.createTextElement('span', formatRelativeTime(comment.created), 'cwd-comment-time')
]
})
@@ -303,9 +317,99 @@ export class CommentItem extends Component {
}
handleLikeComment() {
if (this.props.onLikeComment) {
this.props.onLikeComment(this.props.comment.id);
if (!this.props.onLikeComment) {
return;
}
const commentId = String(this.props.comment.id);
// 防抖检查1秒内同一评论只能操作一次
const now = Date.now();
const debounceKey = `${this.getUserId()}_${commentId}`;
const lastClick = CommentItem._likeDebounce.get(debounceKey);
if (lastClick && now - lastClick < 1000) {
return;
}
CommentItem._likeDebounce.set(debounceKey, now);
// 获取当前点赞状态
const likedComments = this.getLikedComments();
const hasLiked = likedComments.has(commentId);
if (!hasLiked) {
// 未点赞,执行点赞
likedComments.add(commentId);
this.saveLikedComments(likedComments);
this.props.onLikeComment(commentId, true);
}
// 已点赞则不做任何操作
}
/**
* 获取用户唯一标识
* 使用静态缓存确保一致性
* @returns {string} 用户ID
*/
getUserId() {
if (CommentItem._userId) {
return CommentItem._userId;
}
const STORAGE_KEY = 'cwd_comment_user_id';
let userId = localStorage.getItem(STORAGE_KEY);
if (!userId) {
// 生成简单的用户ID
userId = 'u_' + Date.now() + '_' + Math.random().toString(36).substring(2, 12);
localStorage.setItem(STORAGE_KEY, userId);
}
CommentItem._userId = userId;
return userId;
}
/**
* 获取已点赞的评论ID集合
* @returns {Set<string>} 已点赞的评论ID集合
*/
getLikedComments() {
const userId = this.getUserId();
const key = `cwd_comment_liked_${userId}`;
const data = localStorage.getItem(key);
const likedSet = new Set();
if (data) {
try {
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) {
parsed.forEach(id => likedSet.add(String(id)));
}
} catch (e) {
// 解析失败,返回空集合
}
}
return likedSet;
}
/**
* 保存点赞记录到 localStorage
* @param {Set} likedSet - 点赞集合
*/
saveLikedComments(likedSet) {
const userId = this.getUserId();
const key = `cwd_comment_liked_${userId}`;
localStorage.setItem(key, JSON.stringify(Array.from(likedSet)));
}
/**
* 检查是否已点赞
* @param {string|number} commentId - 评论ID
* @returns {boolean} 是否已点赞
*/
hasLiked(commentId) {
const likedComments = this.getLikedComments();
return likedComments.has(String(commentId));
}
handleSubmitReply() {

View File

@@ -31,6 +31,7 @@ export class CommentList extends Component {
* @param {Function} props.onGoToPage - 跳转页码回调
* @param {string} props.adminEmail - 博主邮箱(可选)
* @param {string} props.adminBadge - 博主标识文字(可选)
* @param {boolean} props.enableCommentLike - 是否开启评论点赞
*/
constructor(container, props = {}) {
super(container, props);
@@ -96,6 +97,7 @@ export class CommentList extends Component {
submitting: this.props.submitting,
adminEmail: this.props.adminEmail,
adminBadge: this.props.adminBadge,
enableCommentLike: this.props.enableCommentLike,
onReply: (commentId) => this.handleReply(commentId),
onSubmitReply: (commentId) => this.handleSubmitReply(commentId),
onCancelReply: () => this.handleCancelReply(),

View File

@@ -97,10 +97,41 @@ export class Component {
* @returns {HTMLElement}
*/
createElement(tag, options = {}) {
const el = document.createElement(tag);
const svgTags = new Set([
'svg',
'path',
'circle',
'rect',
'line',
'polyline',
'polygon',
'ellipse',
'g',
'defs',
'clipPath',
'mask',
'pattern',
'text',
'tspan',
'use',
'symbol',
'linearGradient',
'radialGradient',
'stop',
'filter'
]);
const isSvgTag = svgTags.has(String(tag).toLowerCase());
const el = isSvgTag
? document.createElementNS('http://www.w3.org/2000/svg', tag)
: document.createElement(tag);
if (options.className) {
el.className = options.className;
if (isSvgTag) {
el.setAttribute('class', options.className);
} else {
el.className = options.className;
}
}
if (options.attributes) {

View File

@@ -49,7 +49,7 @@ export class CWDComments {
this.likeState = {
count: 0,
liked: false,
loading: false
loading: false,
};
this._likeButtonEl = null;
this._likeCountEl = null;
@@ -99,6 +99,8 @@ export class CWDComments {
adminEnabled: !!data.adminEnabled,
avatarPrefix: data.avatarPrefix || '',
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : [],
enableCommentLike: typeof data.enableCommentLike === 'boolean' ? data.enableCommentLike : true,
enableArticleLike: typeof data.enableArticleLike === 'boolean' ? data.enableArticleLike : true,
};
} catch (e) {
return {};
@@ -137,11 +139,7 @@ export class CWDComments {
}
// 检查域名限制
if (
serverConfig.allowedDomains &&
serverConfig.allowedDomains.length > 0 &&
typeof window !== 'undefined'
) {
if (serverConfig.allowedDomains && serverConfig.allowedDomains.length > 0 && typeof window !== 'undefined') {
const currentHostname = window.location.hostname;
const isAllowed = serverConfig.allowedDomains.some((domain) => {
return currentHostname === domain || currentHostname.endsWith('.' + domain);
@@ -169,6 +167,8 @@ export class CWDComments {
this.config.adminBadge = serverConfig.adminBadge;
}
this.config.requireReview = !!serverConfig.requireReview;
this.config.enableCommentLike = serverConfig.enableCommentLike;
this.config.enableArticleLike = serverConfig.enableArticleLike;
const api = createApiClient(this.config);
this.api = api;
@@ -177,7 +177,7 @@ export class CWDComments {
this.config,
api.fetchComments.bind(api),
api.submitComment.bind(api),
typeof api.likeComment === 'function' ? api.likeComment.bind(api) : undefined
typeof api.likeComment === 'function' ? api.likeComment.bind(api) : undefined,
);
this.unsubscribe = this.store.store.subscribe((state) => {
@@ -195,10 +195,7 @@ export class CWDComments {
if (this.api && typeof this.api.getLikeStatus === 'function') {
try {
const likeResult = await this.api.getLikeStatus();
const count =
likeResult && typeof likeResult.totalLikes === 'number'
? likeResult.totalLikes
: 0;
const count = likeResult && typeof likeResult.totalLikes === 'number' ? likeResult.totalLikes : 0;
const liked = !!(likeResult && likeResult.liked);
this.likeState.count = count;
this.likeState.liked = liked;
@@ -206,8 +203,7 @@ export class CWDComments {
this.store.setLikeState(count, liked);
}
this._updateLikeButton();
} catch (e) {
}
} catch (e) {}
}
})();
@@ -263,7 +259,7 @@ export class CWDComments {
}
const state = this.store.store.getState();
// 创建错误提示
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
if (state.error) {
@@ -311,20 +307,21 @@ export class CWDComments {
if (!header) {
header = document.createElement('div');
header.className = 'cwd-comments-header';
const showArticleLike = this.config.enableArticleLike !== false;
header.innerHTML = `
<h3 class="cwd-comments-count">
共 <span class="cwd-comments-count-number">0</span> 条评论
</h3>
<div class="cwd-like">
<div class="cwd-like" ${showArticleLike ? '' : 'style="display: none;"'}>
<button type="button" class="cwd-like-button" data-liked="false">
<span class="cwd-like-icon-wrapper">
<svg class="cwd-like-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21c-.4 0-.8-.1-1.1-.4L4.5 15C3 13.6 2 11.7 2 9.6 2 6.5 4.5 4 7.6 4c1.7 0 3.3.8 4.4 2.1C13.1 4.8 14.7 4 16.4 4 19.5 4 22 6.5 22 9.6c0 2.1-1 4-2.5 5.4l-6.4 5.6c-.3.3-.7.4-1.1.4z"></path>
</svg>
</span>
<span class="cwd-like-count">0</span>
<div>已有 <span class="cwd-like-count">0</span>人喜欢~ </div>
</button>
</div>
<h3 class="cwd-comments-count">
共 <span class="cwd-comments-count-number">0</span> 条评论
</h3>
`;
this.mountPoint.appendChild(header);
}
@@ -349,7 +346,7 @@ export class CWDComments {
onSubmit: () => this._handleSubmit(),
onFieldChange: (field, value) => this.store.updateFormField(field, value),
adminEmail: this.config.adminEmail,
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
onVerifyAdmin: (key) => this.api.verifyAdminKey(key),
});
this.commentForm.render();
}
@@ -371,6 +368,7 @@ export class CWDComments {
submitting: state.submitting,
adminEmail: this.config.adminEmail,
adminBadge: this.config.adminBadge,
enableCommentLike: this.config.enableCommentLike !== false,
onRetry: () => this.store.loadComments(),
onReply: (commentId) => this.store.startReply(commentId),
onSubmitReply: (commentId) => this.store.submitReply(commentId),
@@ -380,9 +378,9 @@ 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) => {
onLikeComment: (commentId, isLike) => {
if (this.store && typeof this.store.likeComment === 'function') {
this.store.likeComment(commentId);
this.store.likeComment(commentId, isLike);
}
},
});
@@ -415,7 +413,7 @@ export class CWDComments {
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting,
adminEmail: this.config.adminEmail
adminEmail: this.config.adminEmail,
});
}
@@ -564,10 +562,7 @@ export class CWDComments {
if (!this.shadowRoot) {
return;
}
const rawUrl =
this.config && typeof this.config.customCssUrl === 'string'
? this.config.customCssUrl
: '';
const rawUrl = this.config && typeof this.config.customCssUrl === 'string' ? this.config.customCssUrl : '';
const url = rawUrl.trim();
if (!url) {
if (this.customStyleElement && this.customStyleElement.parentNode) {
@@ -624,10 +619,7 @@ export class CWDComments {
}
const state = this.store?.store?.getState();
const liked = state ? !!state.liked : this.likeState.liked;
const count =
state && typeof state.likeCount === 'number'
? state.likeCount
: this.likeState.count;
const count = state && typeof state.likeCount === 'number' ? state.likeCount : this.likeState.count;
this.likeState.count = count;
this.likeState.liked = liked;
this._likeButtonEl.dataset.liked = liked ? 'true' : 'false';
@@ -650,10 +642,7 @@ export class CWDComments {
return;
}
const currentState = this.store?.store?.getState();
const currentCount =
currentState && typeof currentState.likeCount === 'number'
? currentState.likeCount
: this.likeState.count;
const currentCount = currentState && typeof currentState.likeCount === 'number' ? currentState.likeCount : this.likeState.count;
const wasLiked = currentState ? !!currentState.liked : this.likeState.liked;
if (wasLiked) {
return;
@@ -669,10 +658,7 @@ export class CWDComments {
this.api
.likePage()
.then((result) => {
const total =
result && typeof result.totalLikes === 'number'
? result.totalLikes
: nextCount;
const total = result && typeof result.totalLikes === 'number' ? result.totalLikes : nextCount;
const liked = !!(result && result.liked);
this.likeState.count = total;
this.likeState.liked = liked;

View File

@@ -178,7 +178,7 @@ export function createApiClient(config) {
return response.json();
}
async function likeComment(commentId) {
async function likeComment(commentId, isLike = true) {
const id =
typeof commentId === 'number'
? commentId
@@ -188,8 +188,9 @@ export function createApiClient(config) {
if (!Number.isFinite(id) || id <= 0) {
throw new Error('Invalid comment id');
}
const method = isLike ? 'POST' : 'DELETE';
const response = await fetch(`${baseUrl}/api/comments/like`, {
method: 'POST',
method,
headers: {
'Content-Type': 'application/json'
},

View File

@@ -177,7 +177,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
});
}
async function likeComment(commentId) {
async function likeComment(commentId, isLike = true) {
const state = store.getState();
if (!likeCommentFn || state.commentLikeLoadingId === commentId) {
return;
@@ -196,6 +196,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
});
try {
const safeComments = Array.isArray(state.comments) ? state.comments : [];
const delta = isLike ? 1 : -1;
const nextComments = safeComments.map((item) => {
if (!item || typeof item.id !== 'number') {
return item;
@@ -205,9 +206,10 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
typeof item.likes === 'number' && Number.isFinite(item.likes) && item.likes >= 0
? item.likes
: 0;
const nextLikes = Math.max(0, current + delta);
return {
...item,
likes: current + 1,
likes: nextLikes,
};
}
if (Array.isArray(item.replies) && item.replies.length > 0) {
@@ -220,9 +222,10 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
typeof reply.likes === 'number' && Number.isFinite(reply.likes) && reply.likes >= 0
? reply.likes
: 0;
const nextLikes = Math.max(0, current + delta);
return {
...reply,
likes: current + 1,
likes: nextLikes,
};
}
return reply;
@@ -237,7 +240,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
store.setState({
comments: nextComments,
});
await likeCommentFn(id);
await likeCommentFn(id, isLike);
} catch (e) {
} finally {
const latest = store.getState();

View File

@@ -40,11 +40,7 @@
}
.cwd-comments-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
margin-bottom: 16px;
border-bottom: 1px solid var(--cwd-border);
}
@@ -517,17 +513,23 @@
.cwd-like {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
padding: 30px 15px;
}
.cwd-like-button {
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--cwd-border-light, #eaeef2);
border: none;
background: var(--cwd-bg-secondary, #f6f8fa);
background: none;
color: var(--cwd-text-secondary, #6e7781);
cursor: pointer;
font-size: 13px;
@@ -536,8 +538,8 @@
}
.cwd-like-button[data-liked='true'] {
background: rgba(9, 105, 218, 0.08);
border-color: var(--cwd-primary, #0969da);
/* background: rgba(9, 105, 218, 0.08); */
/* border-color: var(--cwd-primary, #0969da); */
color: var(--cwd-primary, #0969da);
}
@@ -554,14 +556,14 @@
}
.cwd-like-icon {
width: 18px;
height: 18px;
width: 32px;
height: 32px;
fill: currentColor;
}
.cwd-like-count {
min-width: 1.5em;
text-align: right;
text-align: center;
font-size: 1rem;
}
.cwd-like-animate .cwd-like-icon {
@@ -589,18 +591,18 @@
.cwd-comment-like {
display: flex;
align-items: center;
display: none;
}
.cwd-comment-like-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
/* gap: 4px; */
/* padding: 2px 8px; */
/* border-radius: 999px; */
border: 1px solid var(--cwd-border-light, #eaeef2);
border: 1px solid transparent;
background: var(--cwd-bg-secondary, #f6f8fa);
background: none;
color: var(--cwd-text-secondary, #6e7781);
cursor: pointer;
font-size: 12px;
@@ -609,8 +611,8 @@
}
.cwd-comment-like-button:hover {
background: rgba(9, 105, 218, 0.08);
border-color: var(--cwd-primary, #0969da);
/* background: rgba(9, 105, 218, 0.08);
border-color: var(--cwd-primary, #0969da); */
color: var(--cwd-primary, #0969da);
}
@@ -627,7 +629,9 @@
}
.cwd-comment-like-count {
min-width: 1.5em;
/* min-width: 1.5em; */
white-space: nowrap;
padding-left: 5px;
text-align: right;
}