Files
cwd/docs/widget/src/components/CommentItem.js
anghunk 4d9e2d8d31 feat(i18n): 为评论系统添加国际化支持
- 在管理后台引入 vue-i18n 并添加多语言文件
- 为前端评论组件添加翻译功能,支持自动语言检测
- 在功能设置中新增管理后台和组件语言配置选项
- 更新 API 接口以支持语言设置存储
- 优化菜单项文本溢出显示样式
2026-02-10 14:26:38 +08:00

468 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CommentItem 评论项组件
*/
import { Component } from './Component.js';
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 - 组件属性
* @param {Object} props.comment - 评论数据
* @param {boolean} props.isReply - 是否为回复
* @param {number|null} props.replyingTo - 当前正在回复的评论 ID
* @param {string} props.replyContent - 回复内容
* @param {string|null} props.replyError - 回复错误
* @param {boolean} props.submitting - 是否正在提交
* @param {string} props.adminEmail - 博主邮箱(可选)
* @param {string} props.adminBadge - 博主标识文字(可选)
* @param {Function} props.onReply - 回复回调
* @param {Function} props.onSubmitReply - 提交回复回调
* @param {Function} props.onCancelReply - 取消回复回调
* @param {Function} props.onUpdateReplyContent - 更新回复内容回调
* @param {Function} props.onClearReplyError - 清除回复错误回调
*/
constructor(container, props = {}) {
super(container, props);
this.t = props.t || ((k) => k);
this.replyEditor = null;
this.childCommentItems = []; // 缓存嵌套回复的 CommentItem 实例
}
render() {
const { comment, isReply, adminEmail, adminBadge } = this.props;
const isPinned = typeof comment.priority === 'number' && comment.priority > 1;
const isReplying = this.props.replyingTo === comment.id;
// 使用后端返回的 isAdmin 字段,不再依赖前端邮箱比对
const isAdmin = !!comment.isAdmin;
const root = this.createElement('div', {
className: `cwd-comment-item ${isReply ? 'cwd-comment-reply' : ''}`,
children: [
// 头像
this.createElement('div', {
className: 'cwd-comment-avatar',
children: [
this.createElement('img', {
attributes: {
src: comment.avatar,
alt: comment.name,
loading: 'lazy',
},
}),
],
}),
// 主体内容
this.createElement('div', {
className: 'cwd-comment-body',
children: [
// 头部(作者名、操作按钮、时间)
this.createElement('div', {
className: 'cwd-comment-header',
children: [
// 作者信息
this.createElement('div', {
className: 'cwd-comment-author',
children: [
comment.url
? this.createElement('span', {
className: 'cwd-author-name',
children: [
this.createElement('a', {
attributes: {
href: comment.url,
target: '_blank',
rel: 'noopener noreferrer',
},
text: comment.name,
}),
],
})
: this.createTextElement('span', comment.name, 'cwd-author-name'),
...(isAdmin
? [
adminBadge
? this.createTextElement('span', `${adminBadge}`, 'cwd-admin-badge')
: this.createElement('span', {
className: 'cwd-admin-badge cwd-admin-badge-icon',
attributes: {
title: '网站管理员',
},
html: '<svg viewBox="0 0 22 22" aria-label="网站管理员" role="img" class="cwd-admin-icon" style="width:15px;height:15px;fill:currentColor;vertical-align:-0.15em;"><g><path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path></g></svg>',
}),
]
: []),
...(isPinned ? [this.createTextElement('span', '置顶', 'cwd-pin-badge')] : []),
// 显示回复目标
...(comment.replyToAuthor
? [
this.createTextElement('span', ` ${this.t('reply')} `, 'cwd-reply-to-separator'),
this.createTextElement('span', comment.replyToAuthor, 'cwd-reply-to-author'),
]
: []),
],
}),
// 操作区域
this.createElement('div', {
className: 'cwd-comment-actions',
children: [
this.createElement('span', {
className: 'cwd-action-btn',
attributes: {
onClick: () => this.handleReply(),
},
text: this.t('reply'),
}),
...(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, this.t), 'cwd-comment-time'),
],
}),
],
}),
// 评论内容
this.createElement('div', {
className: 'cwd-comment-content',
}),
// 回复编辑器容器
this.createElement('div', {
className: 'cwd-reply-editor-container',
}),
// 嵌套回复容器
...(comment.replies && comment.replies.length > 0
? [
this.createElement('div', {
className: 'cwd-replies',
}),
]
: []),
],
}),
],
});
// 设置评论内容的 TEXT
const contentEl = root.querySelector('.cwd-comment-content');
if (contentEl) {
contentEl.innerHTML = comment.contentHtml;
}
// 创建回复编辑器
if (isReplying) {
const replyContainer = root.querySelector('.cwd-reply-editor-container');
if (replyContainer) {
this.replyEditor = new ReplyEditor(replyContainer, {
replyToAuthor: comment.name,
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
onUpdateUserInfo: this.props.onUpdateUserInfo,
onUpdate: (content) => this.handleUpdateReplyContent(content),
onSubmit: () => this.handleSubmitReply(),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError(),
placeholder: this.props.replyPlaceholder,
t: this.t
});
this.replyEditor.render();
this.replyEditor.focus();
}
} else {
this.replyEditor = null;
}
// 渲染嵌套回复
this.childCommentItems = [];
if (comment.replies && comment.replies.length > 0) {
const repliesContainer = root.querySelector('.cwd-replies');
if (repliesContainer) {
comment.replies.forEach((reply) => {
const replyItem = new CommentItem(repliesContainer, {
comment: reply,
isReply: true,
replyingTo: this.props.replyingTo,
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
onUpdateUserInfo: this.props.onUpdateUserInfo,
// adminEmail 已移除
adminBadge: this.props.adminBadge,
enableCommentLike: this.props.enableCommentLike,
replyPlaceholder: this.props.replyPlaceholder,
onReply: this.props.onReply,
onLikeComment: this.props.onLikeComment,
onSubmitReply: this.props.onSubmitReply,
onCancelReply: this.props.onCancelReply,
onUpdateReplyContent: this.props.onUpdateReplyContent,
onClearReplyError: this.props.onClearReplyError,
t: this.t
});
replyItem.render();
this.childCommentItems.push(replyItem);
});
}
}
this.elements.root = root;
// 只在首次渲染时清空容器(当还没有 root 元素时)
if (this.container.contains(root)) {
// 如果 root 已存在,替换它
this.container.replaceChild(root, this.elements.root);
} else {
// 否则直接添加
this.container.appendChild(root);
}
}
updateProps(prevProps) {
const { comment } = this.props;
const wasReplying = prevProps.replyingTo === comment.id;
const isReplying = this.props.replyingTo === comment.id;
// 如果评论数据本身变化,需要完全重新渲染
if (this.props.comment !== prevProps.comment) {
this.render();
return;
}
// 处理回复编辑器的显示/隐藏
if (isReplying !== wasReplying) {
const replyContainer = this.elements.root?.querySelector(':scope > .cwd-comment-body > .cwd-reply-editor-container');
if (isReplying && replyContainer) {
// 显示回复编辑器
this.replyEditor = new ReplyEditor(replyContainer, {
replyToAuthor: comment.name,
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
onUpdateUserInfo: this.props.onUpdateUserInfo,
onUpdate: (content) => this.handleUpdateReplyContent(content),
onSubmit: () => this.handleSubmitReply(),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError(),
placeholder: this.props.replyPlaceholder,
});
this.replyEditor.render();
this.replyEditor.focus();
} else if (!isReplying && replyContainer) {
// 隐藏回复编辑器
replyContainer.innerHTML = '';
this.replyEditor = null;
}
} else if (isReplying && this.replyEditor) {
// 更新回复编辑器的 props
this.replyEditor.setProps({
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
placeholder: this.props.replyPlaceholder,
});
}
// 递归更新嵌套回复
if (this.childCommentItems && this.childCommentItems.length > 0) {
this.childCommentItems.forEach((childItem) => {
childItem.setProps({
replyingTo: this.props.replyingTo,
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
enableCommentLike: this.props.enableCommentLike,
replyPlaceholder: this.props.replyPlaceholder,
onLikeComment: this.props.onLikeComment,
});
});
}
}
handleReply() {
if (this.props.onReply) {
this.props.onReply(this.props.comment.id);
}
}
handleLikeComment() {
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() {
if (this.props.onSubmitReply) {
this.props.onSubmitReply(this.props.comment.id);
}
}
handleCancelReply() {
if (this.props.onCancelReply) {
this.props.onCancelReply();
}
}
handleUpdateReplyContent(content) {
if (this.props.onUpdateReplyContent) {
this.props.onUpdateReplyContent(content);
}
}
handleClearReplyError() {
if (this.props.onClearReplyError) {
this.props.onClearReplyError();
}
}
}