Initial commit

This commit is contained in:
anghunk
2026-01-14 10:02:58 +08:00
commit 8f2fde5188
86 changed files with 74925 additions and 0 deletions

View File

@@ -0,0 +1,262 @@
/**
* CommentForm 评论表单组件
*/
import { Component } from './Component.js';
export class CommentForm extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {Object} props.form - 表单数据
* @param {Object} props.formErrors - 表单错误
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onSubmit - 提交回调
* @param {Function} props.onFieldChange - 字段变化回调
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
localForm: { ...props.form },
};
}
render() {
const { formErrors, submitting } = this.props;
const { localForm } = this.state;
const canSubmit = localForm.author.trim() && localForm.email.trim() && localForm.content.trim();
const root = this.createElement('form', {
className: 'cwd-comment-form',
attributes: {
novalidate: true,
onSubmit: (e) => this.handleSubmit(e),
},
children: [
// 标题
this.createTextElement('h3', '发表评论', 'cwd-form-title'),
// 表单字段
this.createElement('div', {
className: 'cwd-form-fields',
children: [
// 第一行:昵称和邮箱
this.createElement('div', {
className: 'cwd-form-row',
children: [
// 昵称
this.createFormField('昵称 *', 'text', 'author', localForm.author, formErrors.author, '昵称 *'),
// 邮箱
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email, '邮箱 *'),
// 网址
this.createFormField('网址', 'url', 'url', localForm.url, formErrors.url, '网址'),
],
}),
// 评论内容
this.createElement('div', {
className: 'cwd-form-field',
children: [
this.createTextElement('label', '评论内容', 'cwd-form-label'),
this.createElement('textarea', {
className: `cwd-form-textarea ${formErrors.content ? 'cwd-input-error' : ''}`,
attributes: {
placeholder: '写下你的评论...',
rows: 4,
disabled: submitting,
onInput: (e) => this.handleFieldChange('content', e.target.value),
},
}),
...(formErrors.content ? [this.createTextElement('span', formErrors.content, 'cwd-error-text')] : []),
],
}),
],
}),
// 操作按钮
this.createElement('div', {
className: 'cwd-form-actions',
children: [
this.createElement('button', {
className: 'cwd-btn cwd-btn-primary',
attributes: {
type: 'submit',
disabled: submitting || !canSubmit,
},
text: submitting ? '提交中...' : '提交评论',
}),
],
}),
],
});
// 设置输入框的值
this.setInputValues(root, localForm);
this.elements.root = root;
this.empty(this.container);
this.container.appendChild(root);
}
updateProps(prevProps) {
// 只在非提交状态时同步表单数据(避免覆盖用户正在输入的内容)
if (!this.props.submitting && this.props.form !== prevProps.form) {
// 保留当前正在输入的内容
const currentAuthor = this.state.localForm.author || '';
const currentEmail = this.state.localForm.email || '';
const currentUrl = this.state.localForm.url || '';
const currentContent = this.state.localForm.content || '';
this.state.localForm = {
author: this.props.form.author || currentAuthor,
email: this.props.form.email || currentEmail,
url: this.props.form.url || currentUrl,
content: this.props.form.content !== undefined ? this.props.form.content : currentContent,
};
// 同步更新 DOM 值(不重新渲染)
if (this.elements.root) {
this.setInputValues(this.elements.root, this.state.localForm);
}
}
// 更新提交按钮状态和错误提示
if (this.elements.root) {
this.updateFormState();
}
}
/**
* 更新表单状态(按钮、错误提示等)
*/
updateFormState() {
const { formErrors, submitting } = this.props;
const { localForm } = this.state;
const canSubmit = localForm.author.trim() && localForm.email.trim() && localForm.content.trim();
// 更新提交按钮状态
const submitBtn = this.elements.root.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = submitting || !canSubmit;
submitBtn.textContent = submitting ? '提交中...' : '提交评论';
}
// 更新输入框禁用状态
const inputs = this.elements.root.querySelectorAll('input, textarea');
inputs.forEach((input) => {
input.disabled = submitting;
});
// 更新错误提示
this.updateErrors(formErrors);
}
/**
* 更新错误提示
*/
updateErrors(formErrors) {
if (!this.elements.root) return;
// 昵称错误
const authorInput = this.elements.root.querySelector('input[placeholder*="昵称"]');
this.updateFieldError(authorInput, formErrors?.author);
// 邮箱错误
const emailInput = this.elements.root.querySelector('input[placeholder*="邮箱"]');
this.updateFieldError(emailInput, formErrors?.email);
// 网址错误
const urlInput = this.elements.root.querySelector('input[placeholder*="网址"]');
this.updateFieldError(urlInput, formErrors?.url);
// 内容错误
const contentTextarea = this.elements.root.querySelector('textarea');
this.updateFieldError(contentTextarea, formErrors?.content);
}
/**
* 更新单个字段的错误状态
*/
updateFieldError(element, error) {
if (!element) return;
// 移除或添加错误样式
if (error) {
element.classList.add('cwd-input-error');
} else {
element.classList.remove('cwd-input-error');
}
// 查找并更新/移除错误提示元素
const parent = element.parentElement;
let errorSpan = parent.querySelector('.cwd-error-text');
if (error) {
if (!errorSpan) {
errorSpan = document.createElement('span');
errorSpan.className = 'cwd-error-text';
parent.appendChild(errorSpan);
}
errorSpan.textContent = error;
} else if (errorSpan) {
errorSpan.remove();
}
}
/**
* 创建表单字段
*/
createFormField(label, type, fieldName, value, error, placeholder = '') {
return this.createElement('div', {
className: 'cwd-form-field',
children: [
this.createTextElement('label', label, 'cwd-form-label'),
this.createElement('input', {
className: `cwd-form-input ${error ? 'cwd-input-error' : ''}`,
attributes: {
type,
placeholder,
disabled: this.props.submitting,
onInput: (e) => this.handleFieldChange(fieldName, e.target.value),
},
}),
...(error ? [this.createTextElement('span', error, 'cwd-error-text')] : []),
],
});
}
/**
* 设置输入框的值
*/
setInputValues(root, form) {
const authorInput = root.querySelector('input[placeholder*="昵称"]');
const emailInput = root.querySelector('input[placeholder*="邮箱"]');
const urlInput = root.querySelector('input[placeholder*="网址"]');
const contentTextarea = root.querySelector('textarea');
if (authorInput) authorInput.value = form.author || '';
if (emailInput) emailInput.value = form.email || '';
if (urlInput) urlInput.value = form.url || '';
if (contentTextarea) contentTextarea.value = form.content || '';
}
handleFieldChange(field, value) {
this.state.localForm[field] = value;
if (this.props.onFieldChange) {
this.props.onFieldChange(field, value);
}
// 实时更新按钮状态
if (this.elements.root) {
this.updateFormState();
}
}
handleSubmit(e) {
e.preventDefault();
if (this.props.onSubmit) {
// 提交当前表单数据
this.props.onSubmit(this.state.localForm);
}
}
}

View File

@@ -0,0 +1,285 @@
/**
* CommentItem 评论项组件
*/
import { Component } from './Component.js';
import { ReplyEditor } from './ReplyEditor.js';
import { formatRelativeTime } from '@/utils/date.js';
export class CommentItem extends Component {
/**
* @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.replyEditor = null;
this.childCommentItems = []; // 缓存嵌套回复的 CommentItem 实例
}
render() {
const { comment, isReply, adminEmail, adminBadge } = this.props;
const isReplying = this.props.replyingTo === comment.id;
const isAdmin = adminEmail && comment.email === adminEmail;
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.author,
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.author
})
]
})
: this.createTextElement('span', comment.author, 'cwd-author-name'),
// 博主标识
...(isAdmin ? [
this.createTextElement('span', `${adminBadge}`, 'cwd-admin-badge')
] : []),
// 显示回复目标
...(comment.replyToAuthor ? [
this.createTextElement('span', ' 回复 ', '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.createTextElement('span', formatRelativeTime(comment.pubDate), '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'
})
] : [])
]
})
]
});
// 设置评论内容的 HTML
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.author,
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
onUpdate: (content) => this.handleUpdateReplyContent(content),
onSubmit: () => this.handleSubmitReply(),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError()
});
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,
adminEmail: this.props.adminEmail,
adminBadge: this.props.adminBadge,
onReply: this.props.onReply,
onSubmitReply: this.props.onSubmitReply,
onCancelReply: this.props.onCancelReply,
onUpdateReplyContent: this.props.onUpdateReplyContent,
onClearReplyError: this.props.onClearReplyError
});
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.author,
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
onUpdate: (content) => this.handleUpdateReplyContent(content),
onSubmit: () => this.handleSubmitReply(),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError()
});
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
});
}
// 递归更新嵌套回复
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
});
});
}
}
handleReply() {
console.log('[CommentItem] handleReply called, comment.id:', this.props.comment.id);
if (this.props.onReply) {
this.props.onReply(this.props.comment.id);
} else {
console.warn('[CommentItem] onReply callback is missing!');
}
}
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();
}
}
}

View File

@@ -0,0 +1,247 @@
/**
* CommentList 评论列表容器组件
*/
import { Component } from './Component.js';
import { CommentItem } from './CommentItem.js';
import { Loading } from './Loading.js';
import { Pagination } from './Pagination.js';
export class CommentList extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {Array} props.comments - 评论列表
* @param {boolean} props.loading - 是否正在加载
* @param {string|null} props.error - 错误信息
* @param {number} props.currentPage - 当前页码
* @param {number} props.totalPages - 总页数
* @param {number|null} props.replyingTo - 当前正在回复的评论 ID
* @param {string} props.replyContent - 回复内容
* @param {string|null} props.replyError - 回复错误
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onRetry - 重试回调
* @param {Function} props.onReply - 回复回调
* @param {Function} props.onSubmitReply - 提交回复回调
* @param {Function} props.onCancelReply - 取消回复回调
* @param {Function} props.onUpdateReplyContent - 更新回复内容回调
* @param {Function} props.onClearReplyError - 清除回复错误回调
* @param {Function} props.onPrevPage - 上一页回调
* @param {Function} props.onNextPage - 下一页回调
* @param {Function} props.onGoToPage - 跳转页码回调
* @param {string} props.adminEmail - 博主邮箱(可选)
* @param {string} props.adminBadge - 博主标识文字(可选)
*/
constructor(container, props = {}) {
super(container, props);
this.loadingComponent = null;
this.paginationComponent = null;
this.commentItems = new Map(); // 缓存 CommentItem 实例key 为 comment.id
}
render() {
const { comments, loading, error, currentPage, totalPages } = this.props;
console.log('[CommentList] render called, comments:', comments);
console.log('[CommentList] comments.length:', comments?.length);
console.log('[CommentList] loading:', loading);
// 清空容器
this.empty(this.container);
// 加载状态
if (loading && comments.length === 0) {
this.loadingComponent = new Loading(this.container, { text: '加载评论中...' });
this.loadingComponent.render();
this.elements.root = this.loadingComponent.elements.root;
return;
}
// 错误状态
if (error && comments.length === 0) {
const errorEl = this.createElement('div', {
className: 'cwd-error',
children: [
this.createTextElement('span', error),
this.createElement('button', {
className: 'cwd-error-retry',
attributes: {
type: 'button',
onClick: () => this.handleRetry()
},
text: '重试'
})
]
});
this.elements.root = errorEl;
this.container.appendChild(errorEl);
return;
}
// 评论列表容器
const root = this.createElement('div', {
className: 'cwd-comment-list'
});
// 评论列表
if (comments.length > 0) {
const commentsContainer = this.createElement('div', {
className: 'cwd-comments'
});
// 清空旧的缓存
this.commentItems.clear();
comments.forEach((comment, index) => {
console.log(`[CommentList] Rendering comment ${index}:`, comment);
const commentItem = new CommentItem(commentsContainer, {
comment,
replyingTo: this.props.replyingTo,
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting,
adminEmail: this.props.adminEmail,
adminBadge: this.props.adminBadge,
onReply: (commentId) => this.handleReply(commentId),
onSubmitReply: (commentId) => this.handleSubmitReply(commentId),
onCancelReply: () => this.handleCancelReply(),
onUpdateReplyContent: (content) => this.handleUpdateReplyContent(content),
onClearReplyError: () => this.handleClearReplyError()
});
commentItem.render();
// 缓存 CommentItem 实例
this.commentItems.set(comment.id, commentItem);
});
console.log('[CommentList] Final commentsContainer children count:', commentsContainer.children.length);
console.log('[CommentList] Final commentsContainer innerHTML:', commentsContainer.innerHTML);
root.appendChild(commentsContainer);
} else {
// 空状态
const emptyEl = this.createElement('div', {
className: 'cwd-empty',
children: [
this.createTextElement('p', '暂无评论,快来抢沙发吧!', 'cwd-empty-text')
]
});
root.appendChild(emptyEl);
}
// 分页
if (totalPages > 1) {
const paginationContainer = this.createElement('div');
root.appendChild(paginationContainer);
this.paginationComponent = new Pagination(paginationContainer, {
currentPage,
totalPages,
onPrev: () => this.handlePrevPage(),
onNext: () => this.handleNextPage(),
onGoTo: (page) => this.handleGoToPage(page)
});
this.paginationComponent.render();
} else {
this.paginationComponent = null;
}
this.elements.root = root;
this.container.appendChild(root);
}
updateProps(prevProps) {
// 如果状态从加载变为非加载,需要完全重新渲染
if (this.props.loading !== prevProps.loading && !this.props.loading) {
this.render();
return;
}
// 如果评论列表变化,重新渲染
if (this.props.comments !== prevProps.comments) {
this.render();
return;
}
// 如果只是回复状态变化,局部更新 CommentItem 而不是完全重新渲染
if (this.props.replyingTo !== prevProps.replyingTo ||
this.props.replyError !== prevProps.replyError ||
this.props.submitting !== prevProps.submitting) {
// 局部更新所有 CommentItem
this.commentItems.forEach((commentItem) => {
commentItem.setProps({
replyingTo: this.props.replyingTo,
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting
});
});
return;
}
// 如果分页信息变化,更新分页组件
if (this.paginationComponent) {
const pageChanged =
this.props.currentPage !== prevProps.currentPage ||
this.props.totalPages !== prevProps.totalPages;
if (pageChanged) {
this.paginationComponent.props.currentPage = this.props.currentPage;
this.paginationComponent.props.totalPages = this.props.totalPages;
this.paginationComponent.updateProps();
}
}
}
handleRetry() {
if (this.props.onRetry) {
this.props.onRetry();
}
}
handleReply(commentId) {
if (this.props.onReply) {
this.props.onReply(commentId);
}
}
handleSubmitReply(commentId) {
if (this.props.onSubmitReply) {
this.props.onSubmitReply(commentId);
}
}
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();
}
}
handlePrevPage() {
if (this.props.onPrevPage) {
this.props.onPrevPage();
}
}
handleNextPage() {
if (this.props.onNextPage) {
this.props.onNextPage();
}
}
handleGoToPage(page) {
if (this.props.onGoToPage) {
this.props.onGoToPage(page);
}
}
}

View File

@@ -0,0 +1,169 @@
/**
* 基础组件类
*/
/**
* 基础组件类
*/
export class Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
*/
constructor(container, props = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
this.props = props;
this.state = {};
this.elements = {};
this.destroyed = false;
}
/**
* 设置状态并触发更新
* @param {Object|Function} newState - 新状态或状态更新函数
*/
setState(newState) {
if (this.destroyed) return;
const prevState = { ...this.state };
if (typeof newState === 'function') {
this.state = { ...this.state, ...newState(prevState) };
} else {
this.state = { ...this.state, ...newState };
}
this.update(prevState);
}
/**
* 更新属性
* @param {Object} newProps - 新属性
*/
setProps(newProps) {
if (this.destroyed) return;
const prevProps = { ...this.props };
this.props = { ...this.props, ...newProps };
this.updateProps(prevProps);
}
/**
* 渲染组件 - 子类实现
*/
render() {
// 子类实现
}
/**
* 更新视图 - 子类可重写
* @param {Object} prevState - 之前的状态
*/
update(prevState) {
// 子类可重写以实现增量更新
this.render();
}
/**
* 更新属性后的回调 - 子类可重写
* @param {Object} prevProps - 之前的属性
*/
updateProps(prevProps) {
// 子类可重写
}
/**
* 销毁组件
*/
destroy() {
this.destroyed = true;
if (this.container && this.elements.root && this.elements.root.parentNode) {
this.elements.root.parentNode.removeChild(this.elements.root);
}
this.elements = {};
}
// ==================== DOM 创建工具方法 ====================
/**
* 创建元素
* @param {string} tag - 标签名
* @param {Object} options - 选项
* @param {string} options.className - 类名
* @param {Object} options.attributes - 属性
* @param {string} options.text - 文本内容
* @param {string} options.html - HTML 内容
* @param {HTMLElement|Array<HTMLElement>} options.children - 子元素
* @returns {HTMLElement}
*/
createElement(tag, options = {}) {
const el = document.createElement(tag);
if (options.className) {
el.className = options.className;
}
if (options.attributes) {
Object.entries(options.attributes).forEach(([key, value]) => {
if (key.startsWith('on')) {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, value);
} else if (key === 'dataset') {
Object.entries(value).forEach(([dataKey, dataValue]) => {
el.dataset[dataKey] = dataValue;
});
} else if (['disabled', 'checked', 'readonly', 'required', 'autofocus'].includes(key)) {
// 布尔属性:只有值为 true 时才设置
if (value) {
el.setAttribute(key, '');
}
} else {
el.setAttribute(key, value);
}
});
}
if (options.text !== undefined) {
el.textContent = options.text;
}
if (options.html !== undefined) {
el.innerHTML = options.html;
}
if (options.children) {
const children = Array.isArray(options.children) ? options.children : [options.children];
children.forEach(child => {
if (child instanceof HTMLElement) {
el.appendChild(child);
}
});
}
return el;
}
/**
* 创建带文本的元素
* @param {string} tag - 标签名
* @param {string} text - 文本内容
* @param {string} className - 类名
* @returns {HTMLElement}
*/
createTextElement(tag, text, className = '') {
return this.createElement(tag, {
className,
text
});
}
/**
* 清空元素
* @param {HTMLElement} el - 目标元素
*/
empty(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
}

View File

@@ -0,0 +1,45 @@
/**
* Loading 组件
*/
import { Component } from './Component.js';
export class Loading extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {string} props.text - 加载文本
*/
constructor(container, props = {}) {
super(container, {
text: '加载中...',
...props
});
}
render() {
const root = this.createElement('div', {
className: 'cwd-loading',
children: [
this.createElement('div', { className: 'cwd-spinner' }),
this.createTextElement('span', this.props.text, 'cwd-loading-text')
]
});
this.elements.root = root;
this.empty(this.container);
this.container.appendChild(root);
}
/**
* 更新加载文本
* @param {string} text - 新文本
*/
setText(text) {
this.props.text = text;
const textEl = this.elements.root.querySelector('.cwd-loading-text');
if (textEl) {
textEl.textContent = text;
}
}
}

View File

@@ -0,0 +1,124 @@
/**
* Pagination 分页组件
*/
import { Component } from './Component.js';
export class Pagination extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {number} props.currentPage - 当前页码
* @param {number} props.totalPages - 总页数
* @param {Function} props.onPrev - 上一页回调
* @param {Function} props.onNext - 下一页回调
* @param {Function} props.onGoTo - 跳转页码回调
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
currentPage: props.currentPage || 1,
totalPages: props.totalPages || 1
};
}
/**
* 计算显示的页码最多5个
* @returns {number[]}
*/
getDisplayedPages() {
const { currentPage, totalPages } = this.state;
const pages = [];
const maxVisible = 5;
const start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
const end = Math.min(totalPages, start + maxVisible - 1);
for (let i = end - maxVisible + 1; i <= end; i++) {
if (i >= 1) {
pages.push(i);
}
}
return pages.slice(0, maxVisible);
}
render() {
if (this.state.totalPages <= 1) {
this.empty(this.container);
return;
}
const displayedPages = this.getDisplayedPages();
const root = this.createElement('div', {
className: 'cwd-pagination',
children: [
// 上一页按钮
this.createElement('button', {
className: 'cwd-page-btn',
attributes: {
type: 'button',
disabled: this.state.currentPage === 1,
onClick: () => this.handlePrev()
},
text: '上一页'
}),
// 页码按钮
this.createElement('div', {
className: 'cwd-page-numbers',
children: displayedPages.map(page =>
this.createElement('button', {
className: `cwd-page-num ${page === this.state.currentPage ? 'cwd-page-num-active' : ''}`,
attributes: {
type: 'button',
onClick: () => this.handleGoTo(page)
},
text: page.toString()
})
)
}),
// 下一页按钮
this.createElement('button', {
className: 'cwd-page-btn',
attributes: {
type: 'button',
disabled: this.state.currentPage === this.state.totalPages,
onClick: () => this.handleNext()
},
text: '下一页'
})
]
});
this.elements.root = root;
this.empty(this.container);
this.container.appendChild(root);
}
updateProps() {
// 更新状态并重新渲染
this.state.currentPage = this.props.currentPage;
this.state.totalPages = this.props.totalPages;
this.render();
}
handlePrev() {
if (this.props.onPrev) {
this.props.onPrev();
}
}
handleNext() {
if (this.props.onNext) {
this.props.onNext();
}
}
handleGoTo(page) {
if (this.props.onGoTo) {
this.props.onGoTo(page);
}
}
}

View File

@@ -0,0 +1,194 @@
/**
* ReplyEditor 回复编辑器组件
*/
import { Component } from './Component.js';
export class ReplyEditor extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {string} props.replyToAuthor - 被回复的作者名
* @param {string} props.content - 回复内容
* @param {string|null} props.error - 错误信息
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onUpdate - 内容更新回调
* @param {Function} props.onSubmit - 提交回调
* @param {Function} props.onCancel - 取消回调
* @param {Function} props.onClearError - 清除错误回调
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
content: props.content || ''
};
}
render() {
const root = this.createElement('div', {
className: 'cwd-reply-editor',
children: [
// 头部
this.createElement('div', {
className: 'cwd-reply-header',
children: [
this.createTextElement('span', `回复 @${this.props.replyToAuthor}`, 'cwd-reply-to'),
this.createElement('button', {
className: 'cwd-btn-close',
attributes: {
type: 'button',
onClick: () => this.handleCancel()
},
text: '✕'
})
]
}),
// 文本框
this.createElement('textarea', {
className: 'cwd-reply-textarea',
attributes: {
rows: 3,
placeholder: '写下你的回复...',
disabled: this.props.submitting,
onInput: (e) => this.handleInput(e)
}
}),
// 错误提示
...(this.props.error ? [
this.createElement('div', {
className: 'cwd-error-inline cwd-error-small',
children: [
this.createTextElement('span', this.props.error),
this.createElement('button', {
className: 'cwd-error-close',
attributes: {
type: 'button',
onClick: () => this.handleClearError()
},
text: '✕'
})
]
})
] : []),
// 操作按钮
this.createElement('div', {
className: 'cwd-reply-actions',
children: [
this.createElement('button', {
className: 'cwd-btn cwd-btn-primary cwd-btn-small',
attributes: {
type: 'button',
disabled: this.props.submitting || !this.state.content.trim(),
onClick: () => this.handleSubmit()
},
text: this.props.submitting ? '提交中...' : '提交回复'
}),
this.createElement('button', {
className: 'cwd-btn cwd-btn-secondary cwd-btn-small',
attributes: {
type: 'button',
disabled: this.props.submitting,
onClick: () => this.handleCancel()
},
text: '取消'
})
]
})
]
});
// 设置文本框内容
const textarea = root.querySelector('textarea');
if (textarea) {
textarea.value = this.state.content;
}
this.elements.root = root;
this.empty(this.container);
this.container.appendChild(root);
}
updateProps(prevProps) {
// 如果外部传入的 content 变化,更新内部状态
if (this.props.content !== this.state.content && this.props.content !== prevProps?.content) {
this.state.content = this.props.content;
this.render();
return;
}
// 如果有错误显示/隐藏变化,重新渲染
if (this.props.error !== prevProps?.error) {
this.render();
return;
}
// 如果 submitting 状态变化,重新渲染
if (this.props.submitting !== prevProps?.submitting) {
this.render();
return;
}
}
handleInput(e) {
this.state.content = e.target.value;
// 更新提交按钮的禁用状态
const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary');
if (submitBtn) {
submitBtn.disabled = this.props.submitting || !this.state.content.trim();
}
if (this.props.onUpdate) {
this.props.onUpdate(this.state.content);
}
}
handleSubmit() {
if (this.props.onSubmit) {
this.props.onSubmit();
}
}
handleCancel() {
if (this.props.onCancel) {
this.props.onCancel();
}
}
handleClearError() {
if (this.props.onClearError) {
this.props.onClearError();
}
}
/**
* 设置内容
* @param {string} content - 新内容
*/
setContent(content) {
this.state.content = content;
const textarea = this.elements.root?.querySelector('textarea');
if (textarea) {
textarea.value = content;
}
}
/**
* 获取内容
* @returns {string}
*/
getContent() {
return this.state.content;
}
/**
* 聚焦文本框
*/
focus() {
const textarea = this.elements.root?.querySelector('textarea');
if (textarea) {
textarea.focus();
}
}
}

View File

@@ -0,0 +1,377 @@
/**
* CWDComments 主类
* 使用 Shadow DOM 隔离样式
*/
import { createApiClient } from './api.js';
import { createCommentStore } from './store.js';
import { CommentForm } from '@/components/CommentForm.js';
import { CommentList } from '@/components/CommentList.js';
import styles from '@/styles/main.css?inline';
/**
* CWDComments 评论组件主类
*/
export class CWDComments {
/**
* @param {Object} config - 配置对象
* @param {string|HTMLElement} config.el - 挂载元素选择器或 DOM 元素
* @param {string} config.apiBaseUrl - API 基础地址
* @param {string} config.postSlug - 文章标识符
* @param {string} config.postTitle - 文章标题(可选)
* @param {string} config.postUrl - 文章 URL可选
* @param {'light'|'dark'} config.theme - 主题
* @param {number} config.pageSize - 每页评论数
* @param {string} config.avatarPrefix - 头像服务前缀(可选,默认 https://gravatar.com/avatar/
* @param {string} config.adminEmail - 博主邮箱(可选,用于显示博主标识)
* @param {string} config.adminBadge - 博主标识文字(可选,默认"博主"
*/
constructor(config) {
this.config = { ...config };
this.hostElement = this._resolveElement(config.el);
this.shadowRoot = null;
this.mountPoint = null;
this.commentForm = null;
this.commentList = null;
this.store = null;
this.unsubscribe = null;
// 初始加载标志
this._mounted = false;
}
/**
* 解析挂载元素
* @private
*/
_resolveElement(el) {
if (typeof el === 'string') {
const element = document.querySelector(el);
if (!element) {
throw new Error(`元素未找到: ${el}`);
}
if (!(element instanceof HTMLElement)) {
throw new Error(`目标不是 HTMLElement: ${el}`);
}
return element;
}
return el;
}
/**
* 挂载组件
*/
mount() {
if (this._mounted) {
return;
}
// 创建 Shadow DOM
this.shadowRoot = this.hostElement.attachShadow({ mode: 'open' });
// 注入样式
const styleElement = document.createElement('style');
if (typeof styles === 'string') {
styleElement.textContent = styles;
} else if (styles && typeof styles === 'object' && 'default' in styles) {
styleElement.textContent = styles.default;
}
this.shadowRoot.appendChild(styleElement);
// 创建容器
this.mountPoint = document.createElement('div');
this.mountPoint.className = 'cwd-comments-container';
this.shadowRoot.appendChild(this.mountPoint);
// 设置主题
if (this.config.theme) {
this.mountPoint.setAttribute('data-theme', this.config.theme);
}
// 创建 API 客户端和 Store
const api = createApiClient(this.config);
this.store = createCommentStore(
this.config,
api.fetchComments.bind(api),
api.submitComment.bind(api)
);
// 订阅状态变化
this.unsubscribe = this.store.store.subscribe((state) => {
this._onStateChange(state);
});
// 渲染组件
this._render();
// 加载评论
this.store.loadComments();
this._mounted = true;
}
/**
* 卸载组件
*/
unmount() {
if (!this._mounted) {
return;
}
// 销毁组件
if (this.commentForm) {
this.commentForm.destroy();
this.commentForm = null;
}
if (this.commentList) {
this.commentList.destroy();
this.commentList = null;
}
// 取消订阅
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
// 移除 Shadow DOM - 通过替换所有子节点
if (this.hostElement) {
// Shadow DOM 会在清空子节点时自动移除
while (this.hostElement.firstChild) {
this.hostElement.removeChild(this.hostElement.firstChild);
}
}
this.shadowRoot = null;
this.mountPoint = null;
this.store = null;
this._mounted = false;
console.log('[CWDComments] 组件已卸载');
}
/**
* 渲染组件
* @private
*/
_render() {
if (!this.mountPoint) {
return;
}
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)
});
this.commentForm.render();
}
// 创建错误提示
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
if (state.error) {
if (!existingError) {
const errorEl = document.createElement('div');
errorEl.className = 'cwd-error-inline';
errorEl.innerHTML = `
<span>${state.error}</span>
<button type="button" class="cwd-error-close" data-action="clear-error">✕</button>
`;
errorEl.querySelector('[data-action="clear-error"]').addEventListener('click', () => {
this.store.clearError();
});
this.mountPoint.insertBefore(errorEl, this.mountPoint.firstChild);
}
} else if (existingError) {
existingError.remove();
}
// 创建头部统计
let header = this.mountPoint.querySelector('.cwd-comments-header');
if (!header) {
header = document.createElement('div');
header.className = 'cwd-comments-header';
header.innerHTML = `
<h3 class="cwd-comments-count">
共 <span class="cwd-comments-count-number">0</span> 条评论
</h3>
`;
this.mountPoint.appendChild(header);
}
const countEl = header.querySelector('.cwd-comments-count-number');
if (countEl) {
countEl.textContent = state.pagination.totalCount;
}
// 创建评论列表
if (!this.commentList) {
const listContainer = document.createElement('div');
this.mountPoint.appendChild(listContainer);
this.commentList = new CommentList(listContainer, {
comments: state.comments,
loading: state.loading,
error: null,
currentPage: state.pagination.page,
totalPages: this.store.getTotalPages(),
replyingTo: state.replyingTo,
replyContent: state.replyContent,
replyError: state.replyError,
submitting: state.submitting,
adminEmail: this.config.adminEmail,
adminBadge: this.config.adminBadge || '博主',
onRetry: () => this.store.loadComments(),
onReply: (commentId) => this.store.startReply(commentId),
onSubmitReply: (commentId) => this.store.submitReply(commentId),
onCancelReply: () => this.store.cancelReply(),
onUpdateReplyContent: (content) => this.store.updateReplyContent(content),
onClearReplyError: () => this.store.clearReplyError(),
onPrevPage: () => this.store.goToPage(state.pagination.page - 1),
onNextPage: () => this.store.goToPage(state.pagination.page + 1),
onGoToPage: (page) => this.store.goToPage(page)
});
this.commentList.render();
}
}
/**
* 状态变化处理
* @private
*/
_onStateChange(state, prevState) {
if (!this._mounted) {
return;
}
// 根据回复状态显示/隐藏主评论表单
if (this.commentForm?.elements?.root) {
const formRoot = this.commentForm.elements.root;
if (state.replyingTo !== null) {
formRoot.style.display = 'none';
} else {
formRoot.style.display = '';
}
}
// 更新评论表单
if (this.commentForm) {
this.commentForm.setProps({
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting
});
}
// 更新错误提示
const existingError = this.mountPoint?.querySelector('.cwd-error-inline');
if (state.error) {
if (!existingError) {
const errorEl = document.createElement('div');
errorEl.className = 'cwd-error-inline';
errorEl.innerHTML = `
<span>${state.error}</span>
<button type="button" class="cwd-error-close" data-action="clear-error">✕</button>
`;
errorEl.querySelector('[data-action="clear-error"]').addEventListener('click', () => {
this.store.clearError();
});
this.mountPoint?.insertBefore(errorEl, this.mountPoint.firstChild);
}
} else if (existingError) {
existingError.remove();
}
// 更新头部统计
const header = this.mountPoint?.querySelector('.cwd-comments-header');
const countEl = header?.querySelector('.cwd-comments-count-number');
if (countEl) {
countEl.textContent = state.pagination.totalCount;
}
// 更新评论列表
if (this.commentList) {
this.commentList.setProps({
comments: state.comments,
loading: state.loading,
currentPage: state.pagination.page,
totalPages: this.store.getTotalPages(),
replyingTo: state.replyingTo,
replyContent: state.replyContent,
replyError: state.replyError,
submitting: state.submitting
});
}
}
/**
* 处理评论提交
* @private
*/
async _handleSubmit() {
const success = await this.store.submitNewComment();
if (success) {
// 表单内容已在 store 中清空
// 更新表单组件
if (this.commentForm) {
this.commentForm.state.localForm = { ...this.store.store.getState().form };
}
}
}
/**
* 更新配置
* @param {Object} newConfig - 新配置
*/
updateConfig(newConfig) {
const prevConfig = { ...this.config };
Object.assign(this.config, newConfig);
// 更新主题
if (newConfig.theme && this.mountPoint) {
this.mountPoint.setAttribute('data-theme', newConfig.theme);
}
// 如果 postSlug 变化,重新加载评论
if (newConfig.postSlug && newConfig.postSlug !== prevConfig.postSlug) {
// 重新创建 API 客户端和 Store
const api = createApiClient(this.config);
// 取消旧订阅
if (this.unsubscribe) {
this.unsubscribe();
}
// 创建新 store
this.store = createCommentStore(
this.config,
api.fetchComments.bind(api),
api.submitComment.bind(api)
);
// 重新订阅
this.unsubscribe = this.store.store.subscribe((state) => {
this._onStateChange(state);
});
// 加载评论
this.store.loadComments();
}
}
/**
* 获取当前配置
* @returns {Object}
*/
getConfig() {
return { ...this.config };
}
}

83
widget/src/core/api.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* API 请求封装
*/
/**
* 创建 API 客户端
* @param {Object} config - 配置对象
* @param {string} config.apiBaseUrl - API 基础地址
* @param {string} config.postSlug - 文章标识符
* @param {string} config.postTitle - 文章标题(可选)
* @param {string} config.postUrl - 文章 URL可选
* @param {string} config.avatarPrefix - 头像服务前缀(可选)
* @returns {Object}
*/
export function createApiClient(config) {
const baseUrl = config.apiBaseUrl.replace(/\/$/, '');
/**
* 获取评论列表
* @param {number} page - 页码
* @param {number} limit - 每页数量
* @returns {Promise<Object>}
*/
async function fetchComments(page = 1, limit = 20) {
const params = new URLSearchParams({
post_slug: config.postSlug,
page: page.toString(),
limit: limit.toString(),
nested: 'true'
});
// 如果配置了头像前缀,添加到请求参数
if (config.avatarPrefix) {
params.set('avatar_prefix', config.avatarPrefix);
}
const response = await fetch(`${baseUrl}/api/comments?${params}`);
if (!response.ok) {
throw new Error(`获取评论失败: ${response.status} ${response.statusText}`);
}
console.log('[API] fetchComments response:', response);
return response.json();
}
/**
* 提交评论
* @param {Object} data - 评论数据
* @param {string} data.author - 昵称
* @param {string} data.email - 邮箱
* @param {string} data.url - 网址(可选)
* @param {string} data.content - 评论内容
* @param {number} data.parentId - 父评论 ID可选用于回复
* @returns {Promise<Object>}
*/
async function submitComment(data) {
const response = await fetch(`${baseUrl}/api/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
post_slug: config.postSlug,
post_title: config.postTitle,
post_url: config.postUrl,
author: data.author,
email: data.email,
url: data.url || undefined,
content: data.content,
parent_id: data.parentId
})
});
if (!response.ok) {
throw new Error(`提交评论失败: ${response.status} ${response.statusText}`);
}
return response.json();
}
return {
fetchComments,
submitComment
};
}

375
widget/src/core/store.js Normal file
View File

@@ -0,0 +1,375 @@
/**
* 状态管理 - 使用发布-订阅模式
*/
// localStorage 键名
const STORAGE_KEY = 'cwd_user_info';
/**
* 从 localStorage 读取用户信息
* @returns {Object}
*/
function loadUserInfo() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
return {
author: parsed.author || '',
email: parsed.email || '',
url: parsed.url || ''
};
}
} catch (e) {
console.error('读取用户信息失败:', e);
}
return { author: '', email: '', url: '' };
}
/**
* 保存用户信息到 localStorage
* @param {string} author - 昵称
* @param {string} email - 邮箱
* @param {string} url - 网址
*/
function saveUserInfo(author, email, url) {
try {
const data = { author, email, url };
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error('保存用户信息失败:', e);
}
}
/**
* 简单的 Store 类
*/
class Store {
constructor(initialState) {
this.state = { ...initialState };
this.listeners = [];
}
/**
* 获取当前状态
* @returns {Object}
*/
getState() {
return { ...this.state };
}
/**
* 更新状态
* @param {Object} updates - 要更新的属性
*/
setState(updates) {
const prevState = { ...this.state };
this.state = { ...this.state, ...updates };
// 通知所有监听器
this.listeners.forEach(listener => {
listener(this.state, prevState);
});
}
/**
* 订阅状态变化
* @param {Function} listener - 监听器函数
* @returns {Function} - 取消订阅的函数
*/
subscribe(listener) {
this.listeners.push(listener);
// 返回取消订阅的函数
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
/**
* 创建评论状态管理 Store
* @param {Object} config - 配置对象
* @param {Function} fetchComments - 获取评论的函数
* @param {Function} submitComment - 提交评论的函数
* @returns {Object}
*/
export function createCommentStore(config, fetchComments, submitComment) {
// 从 localStorage 加载用户信息
const savedInfo = loadUserInfo();
// 创建 store 实例
const store = new Store({
// 评论数据
comments: [],
loading: false,
error: null,
// 分页
pagination: {
page: 1,
limit: config.pageSize || 20,
total: 0,
totalCount: 0
},
// 表单数据
form: {
author: savedInfo.author || '',
email: savedInfo.email || '',
url: savedInfo.url || '',
content: ''
},
formErrors: {},
submitting: false,
// 回复状态
replyingTo: null,
replyContent: '',
replyError: null
});
// 监听用户信息变化,自动保存到 localStorage
store.subscribe((state) => {
if (state.form.author || state.form.email || state.form.url) {
saveUserInfo(state.form.author, state.form.email, state.form.url);
}
});
/**
* 加载评论列表
* @param {number} page - 页码
*/
async function loadComments(page = 1) {
store.setState({
loading: true,
error: null
});
try {
const response = await fetchComments(page, store.getState().pagination.limit);
console.log('[Store] loadComments response:', response);
console.log('[Store] comments data:', response.data);
store.setState({
comments: response.data,
pagination: {
page: response.pagination.page,
limit: response.pagination.limit,
total: response.pagination.total,
totalCount: response.pagination.totalCount
},
loading: false
});
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '加载评论失败',
loading: false
});
}
}
/**
* 提交评论
*/
async function submitNewComment() {
const state = store.getState();
const form = state.form;
// 验证表单
const { validateCommentForm } = await import('@/utils/validator.js');
const validation = validateCommentForm(form);
if (!validation.valid) {
store.setState({
formErrors: validation.errors
});
return false;
}
// 清空错误
store.setState({
formErrors: {},
submitting: true,
error: null
});
try {
await submitComment({
author: form.author,
email: form.email,
url: form.url,
content: form.content
});
// 清空评论内容
store.setState({
form: { ...form, content: '' },
submitting: false
});
// 重新加载评论
await loadComments(state.pagination.page);
return true;
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '提交评论失败',
submitting: false
});
return false;
}
}
/**
* 提交回复
* @param {number} parentId - 父评论 ID
*/
async function submitReply(parentId) {
const state = store.getState();
// 验证回复内容
if (!state.replyContent.trim()) {
return false;
}
// 验证用户信息
const { validateReplyUserInfo } = await import('@/utils/validator.js');
const validation = validateReplyUserInfo(state.form);
if (!validation.valid) {
const errorMessages = Object.values(validation.errors).join('');
store.setState({
replyError: errorMessages
});
return false;
}
store.setState({
formErrors: {},
submitting: true,
replyError: null
});
try {
await submitComment({
author: state.form.author,
email: state.form.email,
url: state.form.url,
content: state.replyContent,
parentId
});
// 清空回复内容并关闭回复框
store.setState({
replyContent: '',
replyingTo: null,
submitting: false
});
// 重新加载评论
await loadComments(state.pagination.page);
return true;
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '提交回复失败',
submitting: false
});
return false;
}
}
/**
* 开始回复
* @param {number} commentId - 评论 ID
*/
function startReply(commentId) {
console.log('[Store] startReply called with commentId:', commentId);
store.setState({
replyingTo: commentId,
replyContent: '',
replyError: null
});
console.log('[Store] New state:', store.getState());
}
/**
* 取消回复
*/
function cancelReply() {
store.setState({
replyingTo: null,
replyContent: '',
replyError: null
});
}
/**
* 更新表单字段
* @param {string} field - 字段名
* @param {string} value - 值
*/
function updateFormField(field, value) {
const form = { ...store.getState().form };
form[field] = value;
store.setState({ form });
}
/**
* 更新回复内容
* @param {string} content - 回复内容
*/
function updateReplyContent(content) {
store.setState({
replyContent: content
});
}
/**
* 清除回复错误
*/
function clearReplyError() {
store.setState({
replyError: null
});
}
/**
* 清除错误
*/
function clearError() {
store.setState({
error: null
});
}
/**
* 切换页码
* @param {number} page - 页码
*/
function goToPage(page) {
const totalPages = store.getState().pagination.total;
if (page >= 1 && page <= totalPages) {
loadComments(page);
}
}
return {
// Store 实例
store,
// 计算属性方法
getTotalPages: () => {
const state = store.getState();
return state.pagination.total;
},
// 操作方法
loadComments,
submitNewComment,
submitReply,
startReply,
cancelReply,
updateFormField,
updateReplyContent,
clearReplyError,
clearError,
goToPage
};
}

184
widget/src/dev.js Normal file
View File

@@ -0,0 +1,184 @@
/**
* 开发调试脚本
*/
import { CWDComments } from './index.js';
// 本地存储的 key
const STORAGE_KEY = 'cwd-dev-config';
// 默认配置
const DEFAULT_CONFIG = {
apiBaseUrl: 'http://localhost:8788',
postSlug: 'demo-post',
theme: 'light',
avatarPrefix: 'https://gravatar.com/avatar',
};
let widgetInstance = null;
/**
* 从本地存储加载配置
*/
function loadConfigFromStorage() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
return { ...DEFAULT_CONFIG, ...JSON.parse(saved) };
}
} catch (e) {
console.warn('[CWDComments] 读取本地存储失败:', e);
}
return DEFAULT_CONFIG;
}
/**
* 保存配置到本地存储
*/
function saveConfigToStorage(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (e) {
console.warn('[CWDComments] 保存到本地存储失败:', e);
}
}
/**
* 将配置填充到输入框
*/
function populateInputs(config) {
const apiBaseUrlInput = document.getElementById('apiBaseUrl');
const postSlugInput = document.getElementById('postSlug');
const themeSelect = document.getElementById('theme');
const avatarPrefixInput = document.getElementById('avatarPrefix');
if (apiBaseUrlInput) apiBaseUrlInput.value = config.apiBaseUrl || DEFAULT_CONFIG.apiBaseUrl;
if (postSlugInput) postSlugInput.value = config.postSlug || DEFAULT_CONFIG.postSlug;
if (themeSelect) themeSelect.value = config.theme || DEFAULT_CONFIG.theme;
if (avatarPrefixInput) avatarPrefixInput.value = config.avatarPrefix || DEFAULT_CONFIG.avatarPrefix;
}
/**
* 从输入框获取当前配置
*/
function getConfigFromInputs() {
const apiBaseUrl = document.getElementById('apiBaseUrl')?.value || DEFAULT_CONFIG.apiBaseUrl;
const postSlug = document.getElementById('postSlug')?.value || DEFAULT_CONFIG.postSlug;
const theme = document.getElementById('theme')?.value || DEFAULT_CONFIG.theme;
const avatarPrefix = document.getElementById('avatarPrefix')?.value || DEFAULT_CONFIG.avatarPrefix;
return { apiBaseUrl, postSlug, theme, avatarPrefix };
}
/**
* 初始化 widget
*/
function initWidget() {
const config = getConfigFromInputs();
// 保存到本地存储
saveConfigToStorage(config);
// 如果已存在实例,先卸载
if (widgetInstance) {
widgetInstance.unmount();
widgetInstance = null;
}
// 清空容器
const container = document.getElementById('comments');
if (container) {
container.innerHTML = '';
}
// 创建新实例
try {
widgetInstance = new CWDComments({
el: '#comments',
apiBaseUrl: config.apiBaseUrl,
postSlug: config.postSlug,
theme: config.theme,
avatarPrefix: config.avatarPrefix,
pageSize: 20,
adminEmail: 'anghunk@gmail.com', // 博主邮箱,留空不进行匹配
adminBadge: '博主', // 自定义标识,默认为"博主"
});
widgetInstance.mount();
console.log('[CWDComments] Widget 初始化成功', config);
} catch (error) {
console.error('[CWDComments] Widget 初始化失败:', error);
}
}
/**
* 切换主题
*/
function toggleTheme() {
if (!widgetInstance) {
console.warn('[CWDComments] 请先初始化 widget');
return;
}
const currentConfig = widgetInstance.getConfig();
const newTheme = currentConfig.theme === 'light' ? 'dark' : 'light';
widgetInstance.updateConfig({ theme: newTheme });
// 更新下拉框
const themeSelect = document.getElementById('theme');
if (themeSelect) {
themeSelect.value = newTheme;
}
// 保存到本地存储
const config = getConfigFromInputs();
config.theme = newTheme;
saveConfigToStorage(config);
console.log('[CWDComments] 主题已切换为:', newTheme);
}
/**
* 清除本地存储的配置
*/
function clearConfig() {
try {
localStorage.removeItem(STORAGE_KEY);
populateInputs(DEFAULT_CONFIG);
console.log('[CWDComments] 配置已重置为默认值');
} catch (e) {
console.error('[CWDComments] 重置配置失败:', e);
}
}
// 将函数挂载到 window 对象,使其在 HTML 中可访问
window.initWidget = initWidget;
window.toggleTheme = toggleTheme;
window.clearConfig = clearConfig;
// 页面加载完成后自动初始化
document.addEventListener('DOMContentLoaded', () => {
console.log('[CWDComments] 开发模式 - 页面加载完成,正在初始化...');
// 从本地存储加载配置并填充到输入框
const savedConfig = loadConfigFromStorage();
populateInputs(savedConfig);
// 初始化 widget
initWidget();
});
// 监听输入框变化,实时保存
document.addEventListener('DOMContentLoaded', () => {
const inputs = ['apiBaseUrl', 'postSlug', 'theme', 'avatarPrefix'];
inputs.forEach((id) => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => {
const config = getConfigFromInputs();
saveConfigToStorage(config);
});
}
});
});
// 导出类型(用于调试)
window.CWDComments = CWDComments;

27
widget/src/index.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* Momo Comments Widget 入口文件
*
* 使用方法:
* ```html
* <div id="comments"></div>
* <script src="cwd-comments.js"></script>
* <script>
* new CWDComments({
* el: '#comments',
* apiBaseUrl: 'https://api.example.com',
* postSlug: 'my-post'
* }).mount();
* </script>
* ```
*/
import { CWDComments } from './core/CWDComments.js';
// 导出为全局变量(用于 UMD 构建)
if (typeof window !== 'undefined') {
window.CWDComments = CWDComments;
}
// ES Module 默认导出
export default CWDComments;
export { CWDComments };

620
widget/src/styles/main.css Normal file
View File

@@ -0,0 +1,620 @@
/**
* 主样式文件
* 包含所有组件样式,在 Shadow DOM 中使用
*/
@import './variables.css';
/* ========== 全局重置 ========== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ========== 容器 ========== */
.cwd-comments-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji';
font-size: 14px;
line-height: 1.5;
color: var(--cwd-text);
background: var(--cwd-bg);
word-wrap: break-word;
}
/* ========== 头部统计 ========== */
.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;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 20px;
color: var(--cwd-text-secondary, #6e7781);
}
.cwd-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--cwd-border, #d0d7de);
border-top-color: var(--cwd-primary, #0969da);
border-radius: 50%;
animation: cwd-spin 0.6s linear infinite;
}
@keyframes cwd-spin {
to {
transform: rotate(360deg);
}
}
.cwd-loading-text {
font-size: 14px;
}
/* ========== CommentForm 组件 ========== */
.cwd-comment-form {
background: var(--cwd-bg, #ffffff);
border: 1px solid var(--cwd-border, #d0d7de);
border-radius: var(--cwd-radius, 6px);
padding: 20px;
margin-bottom: 24px;
}
.cwd-form-title {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: var(--cwd-text, #24292f);
}
.cwd-form-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.cwd-form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
@media (max-width: 640px) {
.cwd-form-row {
grid-template-columns: 1fr;
}
}
.cwd-form-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.cwd-form-label {
font-size: 13px;
font-weight: 500;
color: var(--cwd-text, #24292f);
}
.cwd-form-field input,
.cwd-form-field textarea {
width: 100%;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
color: var(--cwd-text, #24292f);
background: var(--cwd-bg-input, #ffffff);
border: 1px solid var(--cwd-border, #d0d7de);
border-radius: var(--cwd-radius, 6px);
transition: border-color 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.cwd-form-field input:focus,
.cwd-form-field textarea:focus {
outline: none;
border-color: var(--cwd-primary, #0969da);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
.cwd-form-field input:disabled,
.cwd-form-field textarea:disabled {
background: var(--cwd-bg-disabled, #f6f8fa);
cursor: not-allowed;
}
.cwd-form-field input.cwd-input-error,
.cwd-form-field textarea.cwd-input-error {
border-color: var(--cwd-error, #cf222e);
}
.cwd-form-field textarea {
resize: vertical;
min-height: 80px;
}
.cwd-error-text {
font-size: 12px;
color: var(--cwd-error, #cf222e);
}
.cwd-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.cwd-btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
border: none;
border-radius: var(--cwd-radius, 6px);
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
}
.cwd-btn-primary {
color: #ffffff;
background: var(--cwd-primary, #0969da);
}
.cwd-btn-primary:hover:not(:disabled) {
background: var(--cwd-primary-hover, #0864ca);
}
.cwd-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ========== ReplyEditor 组件 ========== */
.cwd-reply-editor {
margin-top: 12px;
padding: 12px;
background: var(--cwd-bg-reply, #f6f8fa);
border: 1px solid var(--cwd-border, #d0d7de);
border-radius: var(--cwd-radius, 6px);
}
.cwd-reply-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cwd-reply-to {
font-size: 13px;
font-weight: 500;
color: var(--cwd-text-secondary, #6e7781);
}
.cwd-btn-close {
width: 20px;
height: 20px;
padding: 0;
font-size: 14px;
color: var(--cwd-text-secondary, #6e7781);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.cwd-btn-close:hover {
background: var(--cwd-bg-hover, rgba(0, 0, 0, 0.05));
}
.cwd-reply-textarea {
width: 100%;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
color: var(--cwd-text, #24292f);
background: var(--cwd-bg-input, #ffffff);
border: 1px solid var(--cwd-border, #d0d7de);
border-radius: var(--cwd-radius, 6px);
resize: vertical;
font-family: inherit;
margin-bottom: 8px;
}
.cwd-reply-textarea:focus {
outline: none;
border-color: var(--cwd-primary, #0969da);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
.cwd-reply-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.cwd-btn-small {
padding: 4px 10px;
font-size: 12px;
}
.cwd-btn-secondary {
color: var(--cwd-text, #24292f);
background: var(--cwd-bg-secondary, #f6f8fa);
border: 1px solid var(--cwd-border, #d0d7de);
}
.cwd-btn-secondary:hover:not(:disabled) {
background: var(--cwd-bg-hover, #eaeef2);
}
/* ========== CommentItem 组件 ========== */
.cwd-comment-item {
display: flex;
gap: 12px;
padding: 20px 0;
border-bottom: 1px solid var(--cwd-border-light, #eaeef2);
}
/* 鼠标悬停在评论主体上时显示操作按钮 */
.cwd-comment-body:hover .cwd-action-btn {
display: inline-flex !important;
}
.cwd-comment-item:last-child {
border-bottom: none;
}
.cwd-comment-reply {
padding-top: 12px;
padding-bottom: 12px;
}
.cwd-comment-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 5px;
border: 1px solid var(--cwd-border, #d0d7de);
overflow: hidden;
background: var(--cwd-bg-avatar, #f6f8fa)
}
.cwd-comment-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
;
}
.cwd-comment-reply .cwd-comment-avatar {
width: 32px;
height: 32px;
}
.cwd-comment-body {
flex: 1;
min-width: 0;
}
.cwd-comment-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.cwd-author-name {
font-size: 16px;
font-weight: 600;
color: var(--cwd-text, #24292f);
}
.cwd-author-name a {
color: inherit;
text-decoration: none;
}
.cwd-author-name a:hover {
color: var(--cwd-primary, #0969da);
text-decoration: underline;
}
.cwd-comment-author {
display: inline-flex;
align-items: center;
}
.cwd-admin-badge {
background: #db850d;
color: #fff;
border-radius: 3px;
padding: 1px 4px;
font-size: 12px;
margin: 0 4px;
}
.cwd-reply-to-separator {
margin: 0 4px;
}
.cwd-reply-to-author {
font-style: italic;
}
.cwd-comment-time {
font-size: 12px;
color: var(--cwd-text-secondary, #6e7781);
}
.cwd-comment-content {
font-size: 14px;
line-height: 1.6;
color: var(--cwd-text, #24292f);
word-wrap: break-word;
margin-top: 10px;
}
.cwd-comment-content p {
margin: 0 0 8px;
}
.cwd-comment-content p:last-child {
margin-bottom: 0;
}
.cwd-comment-content a {
color: var(--cwd-primary, #0969da);
text-decoration: none;
}
.cwd-comment-content a:hover {
text-decoration: underline;
}
.cwd-comment-actions {
display: flex;
align-items: center;
gap: 12px;
}
.cwd-action-btn {
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--cwd-text-secondary, #6e7781);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
display: none;
}
.cwd-action-btn:hover {
color: var(--cwd-primary, #0969da);
}
.cwd-replies {
margin-top: 12px;
padding-left: 12px;
border-left: 2px solid var(--cwd-border-light, #eaeef2);
}
/* ========== CommentList 组件 ========== */
.cwd-comment-list {
min-height: 100px;
}
.cwd-comments {
display: flex;
flex-direction: column;
}
.cwd-error {
padding: 20px;
text-align: center;
color: var(--cwd-error, #cf222e);
background: var(--cwd-bg-error, #ffebe9);
border: 1px solid var(--cwd-border-error, #fd8c73);
border-radius: var(--cwd-radius, 6px);
}
.cwd-error-retry {
margin-left: 12px;
padding: 4px 12px;
font-size: 13px;
color: #ffffff;
background: var(--cwd-primary, #0969da);
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
}
.cwd-error-retry:hover {
background: var(--cwd-primary-hover, #0864ca);
}
.cwd-empty {
padding: 40px 20px;
text-align: center;
}
.cwd-empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.cwd-empty-text {
margin: 0;
font-size: 14px;
color: var(--cwd-text-secondary, #6e7781);
}
/* ========== Pagination 组件 ========== */
.cwd-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 0;
}
.cwd-page-btn {
padding: 6px 12px;
font-size: 13px;
color: var(--cwd-text, #24292f);
background: var(--cwd-bg, #ffffff);
border: 1px solid var(--cwd-border, #d0d7de);
border-radius: var(--cwd-radius, 6px);
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
}
.cwd-page-btn:hover:not(:disabled) {
background: var(--cwd-bg-hover, #f6f8fa);
}
.cwd-page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cwd-page-numbers {
display: flex;
gap: 4px;
}
.cwd-page-num {
min-width: 32px;
padding: 6px 8px;
font-size: 13px;
color: var(--cwd-text, #24292f);
background: var(--cwd-bg, #ffffff);
border: 1px solid var(--cwd-border, #d0d7de);
border-radius: var(--cwd-radius, 6px);
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
}
.cwd-page-num:hover {
background: var(--cwd-bg-hover, #f6f8fa);
}
.cwd-page-num-active {
color: #ffffff;
background: var(--cwd-primary, #0969da);
border-color: var(--cwd-primary, #0969da);
}
/* ========== App 组件 ========== */
.cwd-error-inline {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin-bottom: 16px;
font-size: 14px;
color: var(--cwd-error, #cf222e);
background: var(--cwd-bg-error, #ffebe9);
border: 1px solid var(--cwd-border-error, #fd8c73);
border-radius: var(--cwd-radius, 6px);
}
.cwd-error-close {
width: 20px;
height: 20px;
padding: 0;
font-size: 14px;
color: inherit;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.cwd-error-close:hover {
background: rgba(0, 0, 0, 0.1);
}
/* ========== 其他 ========== */
.cwd-load-more {
display: block;
width: 100%;
padding: 12px;
margin: 16px 0;
font-size: 14px;
color: var(--cwd-primary);
background: var(--cwd-bg-secondary);
border: 1px solid var(--cwd-border);
border-radius: var(--cwd-radius);
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
}
.cwd-load-more:hover {
background: var(--cwd-bg-hover);
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 滚动条样式 */
.cwd-comments-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.cwd-comments-container::-webkit-scrollbar-track {
background: transparent;
}
.cwd-comments-container::-webkit-scrollbar-thumb {
background: var(--cwd-border);
border-radius: 4px;
}
.cwd-comments-container::-webkit-scrollbar-thumb:hover {
background: var(--cwd-text-secondary);
}

View File

@@ -0,0 +1,60 @@
/**
* CSS 变量定义
* 支持主题切换
*/
:root,
[data-theme="light"] {
/* 主色调 */
--cwd-primary: #0969da;
--cwd-primary-hover: #0864ca;
/* 文字颜色 */
--cwd-text: #24292f;
--cwd-text-secondary: #6e7781;
/* 背景色 */
--cwd-bg: #ffffff;
--cwd-bg-input: #ffffff;
--cwd-bg-secondary: #f6f8fa;
--cwd-bg-reply: #f6f8fa;
--cwd-bg-hover: #f6f8fa;
--cwd-bg-disabled: #f6f8fa;
--cwd-bg-avatar: #f6f8fa;
/* 边框颜色 */
--cwd-border: #d0d7de;
--cwd-border-light: #eaeef2;
/* 状态颜色 */
--cwd-error: #cf222e;
--cwd-bg-error: #ffebe9;
--cwd-border-error: #fd8c73;
/* 圆角 */
--cwd-radius: 6px;
}
/* 深色主题 */
[data-theme="dark"] {
--cwd-primary: #58a6ff;
--cwd-primary-hover: #4094ff;
--cwd-text: #c9d1d9;
--cwd-text-secondary: #8b949e;
--cwd-bg: #0d1117;
--cwd-bg-input: #0d1117;
--cwd-bg-secondary: #161b22;
--cwd-bg-reply: #161b22;
--cwd-bg-hover: #161b22;
--cwd-bg-disabled: #161b22;
--cwd-bg-avatar: #161b22;
--cwd-border: #30363d;
--cwd-border-light: #21262d;
--cwd-error: #f85149;
--cwd-bg-error: #3d1614;
--cwd-border-error: #f85149;
}

78
widget/src/utils/date.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* 日期时间格式化工具
*/
/**
* 格式化时间3天内显示相对时间超过3天显示完整日期
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatRelativeTime(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
// 3天内显示相对时间
if (days < 3) {
if (days > 0) {
return days === 1 ? '昨天' : `${days}天前`;
}
if (hours > 0) {
return `${hours}小时前`;
}
if (minutes > 0) {
return `${minutes}分钟前`;
}
return '刚刚';
}
// 超过3天显示完整日期
return formatDateTime(dateStr);
}
/**
* 格式化日期时间
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatDateTime(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 格式化日期
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatDate(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
}
/**
* 格式化时间
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatTime(dateStr) {
const date = new Date(dateStr);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}

210
widget/src/utils/dom.js Normal file
View File

@@ -0,0 +1,210 @@
/**
* DOM 操作工具函数
*/
/**
* 创建元素
* @param {string} tag - 标签名
* @param {string} className - 类名
* @param {Object} attributes - 属性对象,支持 onClick 等事件监听器
* @param {string|HTMLElement|HTMLElement[]} content - 内容
* @returns {HTMLElement}
*/
export function createElement(tag, className = '', attributes = {}, content = null) {
const el = document.createElement(tag);
if (className) {
el.className = className;
}
Object.entries(attributes).forEach(([key, value]) => {
if (key.startsWith('on')) {
// 事件监听器,如 onClick -> click
const event = key.slice(2).toLowerCase();
el.addEventListener(event, value);
} else if (key === 'dataset') {
// 设置 data-* 属性
Object.entries(value).forEach(([dataKey, dataValue]) => {
el.dataset[dataKey] = dataValue;
});
} else {
el.setAttribute(key, value);
}
});
// 处理内容
if (content !== null) {
appendContent(el, content);
}
return el;
}
/**
* 添加内容到元素
* @param {HTMLElement} el - 目标元素
* @param {string|HTMLElement|HTMLElement[]} content - 内容
*/
export function appendContent(el, content) {
if (typeof content === 'string') {
el.textContent = content;
} else if (Array.isArray(content)) {
content.forEach(child => {
if (child instanceof HTMLElement) {
el.appendChild(child);
}
});
} else if (content instanceof HTMLElement) {
el.appendChild(content);
}
}
/**
* 设置元素的 HTML 内容
* @param {HTMLElement} el - 目标元素
* @param {string} html - HTML 字符串
*/
export function setHTML(el, html) {
el.innerHTML = html;
}
/**
* 清空元素内容
* @param {HTMLElement} el - 目标元素
*/
export function empty(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
/**
* 显示元素
* @param {HTMLElement} el - 目标元素
*/
export function show(el) {
el.style.display = '';
}
/**
* 隐藏元素
* @param {HTMLElement} el - 目标元素
*/
export function hide(el) {
el.style.display = 'none';
}
/**
* 切换元素显示状态
* @param {HTMLElement} el - 目标元素
* @param {boolean} visible - 是否显示
*/
export function toggle(el, visible) {
if (visible) {
show(el);
} else {
hide(el);
}
}
/**
* 添加类名
* @param {HTMLElement} el - 目标元素
* @param {string} className - 类名
*/
export function addClass(el, className) {
el.classList.add(className);
}
/**
* 移除类名
* @param {HTMLElement} el - 目标元素
* @param {string} className - 类名
*/
export function removeClass(el, className) {
el.classList.remove(className);
}
/**
* 切换类名
* @param {HTMLElement} el - 目标元素
* @param {string} className - 类名
* @param {boolean} force - 强制添加或移除
*/
export function toggleClass(el, className, force) {
el.classList.toggle(className, force);
}
/**
* 查找元素
* @param {string|HTMLElement} selector - 选择器或元素
* @param {HTMLElement} context - 上下文元素
* @returns {HTMLElement|null}
*/
export function query(selector, context = document) {
if (typeof selector === 'string') {
return context.querySelector(selector);
}
return selector;
}
/**
* 查找所有元素
* @param {string} selector - 选择器
* @param {HTMLElement} context - 上下文元素
* @returns {NodeList}
*/
export function queryAll(selector, context = document) {
return context.querySelectorAll(selector);
}
/**
* 委托事件监听
* @param {HTMLElement} el - 目标元素
* @param {string} event - 事件名
* @param {string} selector - 选择器
* @param {Function} handler - 处理函数
*/
export function delegate(el, event, selector, handler) {
el.addEventListener(event, (e) => {
const target = e.target.closest(selector);
if (target && el.contains(target)) {
handler.call(target, e);
}
});
}
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} wait - 等待时间
* @returns {Function}
*/
export function debounce(func, wait = 300) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 节流函数
* @param {Function} func - 要节流的函数
* @param {number} limit - 限制时间
* @returns {Function}
*/
export function throttle(func, limit = 300) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}

View File

@@ -0,0 +1,122 @@
/**
* 表单验证工具
*/
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean}
*/
export function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* 验证 URL 格式
* @param {string} url - URL 地址
* @returns {boolean}
*/
export function isValidUrl(url) {
if (!url) return true; // URL 是可选的
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* 验证评论内容
* @param {string} content - 评论内容
* @returns {{valid: boolean, error?: string}}
*/
export function validateCommentContent(content) {
if (!content || content.trim().length === 0) {
return { valid: false, error: '请输入评论内容' };
}
if (content.length > 1000) {
return { valid: false, error: '评论内容不能超过 1000 字' };
}
return { valid: true };
}
/**
* 验证评论表单
* @param {Object} data - 表单数据
* @param {string} data.author - 昵称
* @param {string} data.email - 邮箱
* @param {string} data.url - 网址
* @param {string} data.content - 评论内容
* @returns {{valid: boolean, errors: Object<string, string>}}
*/
export function validateCommentForm(data) {
const errors = {};
// 验证昵称
if (!data.author || data.author.trim().length === 0) {
errors.author = '请输入昵称';
} else if (data.author.length > 50) {
errors.author = '昵称不能超过 50 字';
}
// 验证邮箱
if (!data.email || data.email.trim().length === 0) {
errors.email = '请输入邮箱';
} else if (!isValidEmail(data.email)) {
errors.email = '邮箱格式不正确';
}
// 验证网址(可选)
if (data.url && !isValidUrl(data.url)) {
errors.url = '网址格式不正确';
}
// 验证评论内容
const contentValidation = validateCommentContent(data.content);
if (!contentValidation.valid) {
errors.content = contentValidation.error;
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}
/**
* 验证回复所需的用户信息
* @param {Object} data - 用户信息
* @param {string} data.author - 昵称
* @param {string} data.email - 邮箱
* @param {string} data.url - 网址
* @returns {{valid: boolean, errors: Object<string, string>}}
*/
export function validateReplyUserInfo(data) {
const errors = {};
// 验证昵称
if (!data.author || data.author.trim().length === 0) {
errors.author = '请输入昵称';
} else if (data.author.length > 50) {
errors.author = '昵称不能超过 50 字';
}
// 验证邮箱
if (!data.email || data.email.trim().length === 0) {
errors.email = '请输入邮箱';
} else if (!isValidEmail(data.email)) {
errors.email = '邮箱格式不正确';
}
// 验证网址(可选)
if (data.url && !isValidUrl(data.url)) {
errors.url = '网址格式不正确';
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}

21
widget/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module '*?inline' {
const content: string;
export default content;
}
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
readonly BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}