feat: 实现文章点赞功能及相关API和UI组件

- 新增点赞功能API接口及数据库表结构
- 添加点赞状态管理及UI交互组件
- 实现点赞统计和列表查询功能
- 优化评论组件布局,将点赞按钮加入头部区域
This commit is contained in:
anghunk
2026-01-22 17:40:12 +08:00
parent 83088a3837
commit 103e0ceb25
9 changed files with 675 additions and 20 deletions

View File

@@ -42,10 +42,17 @@ export class CWDComments {
this.mountPoint = null;
this.commentForm = null;
this.commentList = null;
this.formContainer = null;
this.store = null;
this.unsubscribe = null;
this.likeState = {
count: 0,
liked: false,
loading: false
};
this._likeButtonEl = null;
this._likeCountEl = null;
// 初始加载标志
this._mounted = false;
}
@@ -181,6 +188,24 @@ export class CWDComments {
if (this.api && typeof this.api.trackVisit === 'function') {
this.api.trackVisit();
}
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 liked = !!(likeResult && likeResult.liked);
this.likeState.count = count;
this.likeState.liked = liked;
if (this.store && typeof this.store.setLikeState === 'function') {
this.store.setLikeState(count, liked);
}
this._updateLikeButton();
} catch (e) {
}
}
})();
this._mounted = true;
@@ -235,21 +260,7 @@ export class CWDComments {
}
const state = this.store.store.getState();
// 创建评论表单
if (!this.commentForm) {
this.commentForm = new CommentForm(this.mountPoint, {
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting,
onSubmit: () => this._handleSubmit(),
onFieldChange: (field, value) => this.store.updateFormField(field, value),
adminEmail: this.config.adminEmail,
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
});
this.commentForm.render();
}
// 创建错误提示
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
if (state.error) {
@@ -301,6 +312,16 @@ export class CWDComments {
<h3 class="cwd-comments-count">
共 <span class="cwd-comments-count-number">0</span> 条评论
</h3>
<div class="cwd-like">
<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>
</button>
</div>
`;
this.mountPoint.appendChild(header);
}
@@ -309,6 +330,27 @@ export class CWDComments {
countEl.textContent = state.pagination.totalCount;
}
this._initLikeButton(header);
if (!this.formContainer) {
this.formContainer = document.createElement('div');
this.mountPoint.appendChild(this.formContainer);
}
// 创建评论表单(放在点赞区域下方,使用单独容器以保证顺序)
if (!this.commentForm) {
this.commentForm = new CommentForm(this.formContainer, {
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting,
onSubmit: () => this._handleSubmit(),
onFieldChange: (field, value) => this.store.updateFormField(field, value),
adminEmail: this.config.adminEmail,
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
});
this.commentForm.render();
}
// 创建评论列表
if (!this.commentList) {
const listContainer = document.createElement('div');
@@ -418,6 +460,21 @@ export class CWDComments {
countEl.textContent = state.pagination.totalCount;
}
if (typeof state.likeCount === 'number' || typeof state.liked === 'boolean') {
if (typeof state.likeCount === 'number') {
this.likeState.count = state.likeCount;
}
if (typeof state.liked === 'boolean') {
this.likeState.liked = state.liked;
}
if (header) {
if (!this._likeButtonEl || !this._likeCountEl) {
this._initLikeButton(header);
}
this._updateLikeButton();
}
}
// 更新评论列表
if (this.commentList) {
this.commentList.setProps({
@@ -494,6 +551,111 @@ export class CWDComments {
}
}
_initLikeButton(header) {
if (!header) {
return;
}
if (!this._likeButtonEl) {
this._likeButtonEl = header.querySelector('.cwd-like-button');
if (this._likeButtonEl) {
this._likeButtonEl.addEventListener('click', () => {
this._handleLikeClick();
});
}
}
if (!this._likeCountEl) {
this._likeCountEl = header.querySelector('.cwd-like-count');
}
this._updateLikeButton();
}
_updateLikeButton(animate = false) {
if (!this._likeButtonEl) {
if (!this.mountPoint) {
return;
}
const header = this.mountPoint.querySelector('.cwd-comments-header');
if (!header) {
return;
}
this._initLikeButton(header);
}
if (!this._likeButtonEl) {
return;
}
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;
this.likeState.count = count;
this.likeState.liked = liked;
this._likeButtonEl.dataset.liked = liked ? 'true' : 'false';
this._likeButtonEl.dataset.loading = this.likeState.loading ? 'true' : 'false';
if (this._likeCountEl) {
this._likeCountEl.textContent = String(count);
}
if (animate && this._likeButtonEl) {
this._likeButtonEl.classList.remove('cwd-like-animate');
void this._likeButtonEl.offsetWidth;
this._likeButtonEl.classList.add('cwd-like-animate');
}
}
_handleLikeClick() {
if (!this.api || typeof this.api.likePage !== 'function') {
return;
}
if (this.likeState.loading) {
return;
}
const currentState = this.store?.store?.getState();
const currentCount =
currentState && typeof currentState.likeCount === 'number'
? currentState.likeCount
: this.likeState.count;
const wasLiked = currentState ? !!currentState.liked : this.likeState.liked;
if (wasLiked) {
return;
}
const nextCount = currentCount + 1;
this.likeState.loading = true;
this.likeState.count = nextCount;
this.likeState.liked = true;
if (this.store && typeof this.store.setLikeState === 'function') {
this.store.setLikeState(nextCount, true);
}
this._updateLikeButton(true);
this.api
.likePage()
.then((result) => {
const total =
result && typeof result.totalLikes === 'number'
? result.totalLikes
: nextCount;
const liked = !!(result && result.liked);
this.likeState.count = total;
this.likeState.liked = liked;
if (this.store && typeof this.store.setLikeState === 'function') {
this.store.setLikeState(total, liked);
}
this._updateLikeButton();
})
.catch(() => {
this.likeState.count = currentCount;
this.likeState.liked = wasLiked;
if (this.store && typeof this.store.setLikeState === 'function') {
this.store.setLikeState(currentCount, wasLiked);
}
this._updateLikeButton();
})
.finally(() => {
this.likeState.loading = false;
this._updateLikeButton();
});
}
/**
* 获取当前配置
* @returns {Object}

View File

@@ -11,9 +11,27 @@
* @param {string} config.postUrl - 文章 URL可选
* @returns {Object}
*/
export function createApiClient(config) {
export function createApiClient(config) {
const baseUrl = config.apiBaseUrl.replace(/\/$/, '');
function getLikeUserId() {
try {
const storageKey = 'cwd_like_uid';
let token = localStorage.getItem(storageKey);
if (!token) {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
token = crypto.randomUUID();
} else {
token = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
localStorage.setItem(storageKey, token);
}
return token;
} catch (e) {
return 'anonymous';
}
}
/**
* 获取评论列表
* @param {number} page - 页码
@@ -114,10 +132,58 @@
}
}
async function getLikeStatus() {
const params = new URLSearchParams({
post_slug: config.postSlug
});
const headers = {
'X-CWD-Like-User': getLikeUserId()
};
const response = await fetch(`${baseUrl}/api/like?${params.toString()}`, {
method: 'GET',
headers
});
if (!response.ok) {
return {
liked: false,
alreadyLiked: false,
totalLikes: 0
};
}
return response.json();
}
async function likePage() {
const headers = {
'Content-Type': 'application/json',
'X-CWD-Like-User': getLikeUserId()
};
const response = await fetch(`${baseUrl}/api/like`, {
method: 'POST',
headers,
body: JSON.stringify({
postSlug: config.postSlug,
postTitle: config.postTitle,
postUrl: config.postUrl
})
});
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
trackVisit,
getLikeStatus,
likePage
};
}

View File

@@ -126,6 +126,8 @@ export function createCommentStore(config, fetchComments, submitComment) {
replyingTo: null,
replyContent: '',
replyError: null,
likeCount: 0,
liked: false,
});
// 监听用户信息变化,自动保存到 localStorage
@@ -165,6 +167,14 @@ export function createCommentStore(config, fetchComments, submitComment) {
}
}
function setLikeState(likeCount, liked) {
const safeCount = typeof likeCount === 'number' && Number.isFinite(likeCount) && likeCount >= 0 ? likeCount : 0;
store.setState({
likeCount: safeCount,
liked: !!liked,
});
}
/**
* 提交评论
*/
@@ -362,7 +372,7 @@ export function createCommentStore(config, fetchComments, submitComment) {
}
}
return {
return {
// Store 实例
store,
@@ -384,5 +394,6 @@ export function createCommentStore(config, fetchComments, submitComment) {
clearError,
clearSuccess,
goToPage,
setLikeState,
};
}

View File

@@ -39,6 +39,21 @@
color: var(--cwd-text);
}
.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);
}
.cwd-comments-count {
font-size: 16px;
font-weight: 600;
color: var(--cwd-text);
}
/* ========== Loading 组件 ========== */
.cwd-loading {
display: flex;
@@ -499,6 +514,72 @@
height: auto;
}
.cwd-like {
display: flex;
align-items: center;
gap: 8px;
}
.cwd-like-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--cwd-border-light, #eaeef2);
background: var(--cwd-bg-secondary, #f6f8fa);
color: var(--cwd-text-secondary, #6e7781);
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.cwd-like-button[data-liked='true'] {
background: rgba(9, 105, 218, 0.08);
border-color: var(--cwd-primary, #0969da);
color: var(--cwd-primary, #0969da);
}
.cwd-like-button[data-loading='true'] {
opacity: 0.6;
cursor: default;
pointer-events: none;
}
.cwd-like-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
}
.cwd-like-icon {
width: 18px;
height: 18px;
fill: currentColor;
}
.cwd-like-count {
min-width: 1.5em;
text-align: right;
}
.cwd-like-animate .cwd-like-icon {
animation: cwd-like-bounce 0.3s ease;
}
@keyframes cwd-like-bounce {
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1.05);
}
}
.cwd-comment-actions {
display: flex;
align-items: center;