/** * 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: '', }), ] : []), ...(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} 已点赞的评论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(); } } }