feat(widget): 新增评论组件核心功能及开发环境配置
新增评论组件核心功能,包括评论列表、分页、回复、表单验证等功能 添加开发环境配置,包括Vite构建配置、样式变量和工具函数 实现管理员验证功能,支持本地存储加密 添加组件基础类及常用组件如加载状态、分页、模态框等 配置文档站点构建流程,支持widget独立构建和集成
This commit is contained in:
137
docs/widget/src/components/AdminAuthModal.js
Normal file
137
docs/widget/src/components/AdminAuthModal.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Component } from './Component.js';
|
||||
|
||||
export class AdminAuthModal extends Component {
|
||||
constructor(container, props = {}) {
|
||||
super(container, props);
|
||||
this.state = {
|
||||
key: '',
|
||||
error: '',
|
||||
loading: false
|
||||
};
|
||||
this.elements = {
|
||||
input: null,
|
||||
error: null,
|
||||
submitBtn: null
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { key, error, loading } = this.state;
|
||||
|
||||
const overlay = this.createElement('div', {
|
||||
className: 'cwd-modal-overlay',
|
||||
children: [
|
||||
this.createElement('div', {
|
||||
className: 'cwd-modal',
|
||||
children: [
|
||||
this.createTextElement('h3', '管理员身份验证', 'cwd-modal-title'),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-modal-body',
|
||||
children: [
|
||||
this.createTextElement('p', '检测到管理员邮箱,请输入密钥以继续。', 'cwd-modal-desc'),
|
||||
this.createElement('input', {
|
||||
className: `cwd-form-input ${error ? 'cwd-input-error' : ''}`,
|
||||
attributes: {
|
||||
type: 'password',
|
||||
placeholder: '请输入管理员密钥',
|
||||
value: key,
|
||||
disabled: loading,
|
||||
onInput: (e) => this.setState({ key: e.target.value, error: '' }),
|
||||
onKeydown: (e) => {
|
||||
if (e.key === 'Enter') this.handleSubmit();
|
||||
}
|
||||
}
|
||||
}),
|
||||
error ? this.createTextElement('div', error, 'cwd-error-text') : null
|
||||
]
|
||||
}),
|
||||
this.createElement('div', {
|
||||
className: 'cwd-modal-actions',
|
||||
children: [
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn cwd-btn-secondary',
|
||||
text: '取消',
|
||||
attributes: {
|
||||
type: 'button',
|
||||
disabled: loading,
|
||||
onClick: () => this.props.onCancel && this.props.onCancel()
|
||||
}
|
||||
}),
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn cwd-btn-primary',
|
||||
text: loading ? '验证中...' : '验证',
|
||||
attributes: {
|
||||
type: 'button',
|
||||
disabled: loading || !key,
|
||||
onClick: () => this.handleSubmit()
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
this.empty(this.container);
|
||||
this.container.appendChild(overlay);
|
||||
|
||||
this.elements.input = overlay.querySelector('input');
|
||||
this.elements.error = overlay.querySelector('.cwd-error-text');
|
||||
this.elements.submitBtn = overlay.querySelector('.cwd-btn-primary');
|
||||
|
||||
this.update();
|
||||
|
||||
const input = this.elements.input;
|
||||
if (input) setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
setState(newState) {
|
||||
this.state = { ...this.state, ...newState };
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
const { key, error, loading } = this.state;
|
||||
|
||||
if (this.elements.input) {
|
||||
this.elements.input.value = key;
|
||||
this.elements.input.disabled = loading;
|
||||
if (error) {
|
||||
this.elements.input.classList.add('cwd-input-error');
|
||||
} else {
|
||||
this.elements.input.classList.remove('cwd-input-error');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.elements.error) {
|
||||
if (error) {
|
||||
this.elements.error.textContent = error;
|
||||
this.elements.error.style.display = '';
|
||||
} else {
|
||||
this.elements.error.textContent = '';
|
||||
this.elements.error.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.elements.submitBtn) {
|
||||
this.elements.submitBtn.disabled = loading || !key;
|
||||
this.elements.submitBtn.textContent = loading ? '验证中...' : '验证';
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
if (!this.state.key) return;
|
||||
if (this.props.onSubmit) {
|
||||
this.setState({ loading: true });
|
||||
this.props.onSubmit(this.state.key)
|
||||
.catch(err => {
|
||||
this.setState({ error: err.message || '验证失败', loading: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.empty(this.container);
|
||||
}
|
||||
}
|
||||
332
docs/widget/src/components/CommentForm.js
Normal file
332
docs/widget/src/components/CommentForm.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* CommentForm 评论表单组件
|
||||
*/
|
||||
|
||||
import { Component } from './Component.js';
|
||||
import { AdminAuthModal } from './AdminAuthModal.js';
|
||||
import { auth } from '../utils/auth.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 - 字段变化回调
|
||||
* @param {string} props.adminEmail - 管理员邮箱
|
||||
* @param {Function} props.onVerifyAdmin - 验证管理员回调 (returns Promise)
|
||||
*/
|
||||
constructor(container, props = {}) {
|
||||
super(container, props);
|
||||
this.state = {
|
||||
localForm: { ...props.form },
|
||||
};
|
||||
this.modal = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formErrors, submitting } = this.props;
|
||||
const { localForm } = this.state;
|
||||
|
||||
const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim();
|
||||
const isAdmin = this.props.adminEmail && localForm.email.trim() === this.props.adminEmail;
|
||||
const isVerified = isAdmin && auth.hasToken();
|
||||
|
||||
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', 'name', localForm.name, formErrors.name),
|
||||
// 邮箱
|
||||
this.createElement('div', {
|
||||
className: 'cwd-form-field-wrapper',
|
||||
children: [
|
||||
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email),
|
||||
isVerified
|
||||
? this.createElement('div', {
|
||||
className: 'cwd-admin-controls',
|
||||
children: [
|
||||
this.createElement('button', {
|
||||
className: 'cwd-btn-text',
|
||||
text: '退出验证',
|
||||
attributes: {
|
||||
type: 'button',
|
||||
title: '清除管理员凭证',
|
||||
onClick: () => {
|
||||
auth.clearToken();
|
||||
this.render();
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
: null,
|
||||
],
|
||||
}),
|
||||
// 网址
|
||||
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 currentName = this.state.localForm.name || '';
|
||||
const currentEmail = this.state.localForm.email || '';
|
||||
const currentUrl = this.state.localForm.url || '';
|
||||
const currentContent = this.state.localForm.content || '';
|
||||
|
||||
this.state.localForm = {
|
||||
name: this.props.form.name || currentName,
|
||||
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.name.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 nameInput = this.elements.root.querySelector('input[name="name"]');
|
||||
this.updateFieldError(nameInput, formErrors?.name);
|
||||
|
||||
const emailInput = this.elements.root.querySelector('input[name="email"]');
|
||||
this.updateFieldError(emailInput, formErrors?.email);
|
||||
|
||||
const urlInput = this.elements.root.querySelector('input[name="url"]');
|
||||
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,
|
||||
name: fieldName,
|
||||
value: value || '',
|
||||
disabled: this.props.submitting,
|
||||
onInput: (e) => this.handleFieldChange(fieldName, e.target.value),
|
||||
onBlur: (e) => {
|
||||
if (fieldName === 'email') this.handleEmailBlur(e.target.value);
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(error ? [this.createTextElement('span', error, 'cwd-error-text')] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置输入框的值
|
||||
*/
|
||||
setInputValues(root, form) {
|
||||
const nameInput = root.querySelector('input[name="name"]');
|
||||
const emailInput = root.querySelector('input[name="email"]');
|
||||
const urlInput = root.querySelector('input[name="url"]');
|
||||
const contentTextarea = root.querySelector('textarea');
|
||||
|
||||
if (nameInput) nameInput.value = form.name || '';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEmailBlur(email) {
|
||||
if (!email || !this.props.adminEmail) return;
|
||||
if (email.trim() === this.props.adminEmail) {
|
||||
// Check local storage
|
||||
if (auth.hasToken()) {
|
||||
// Already valid
|
||||
return;
|
||||
}
|
||||
// Show modal
|
||||
this.showAuthModal();
|
||||
}
|
||||
}
|
||||
|
||||
showAuthModal() {
|
||||
// Create modal container if not exists
|
||||
let modalContainer = this.elements.root.querySelector('.cwd-modal-container');
|
||||
if (!modalContainer) {
|
||||
modalContainer = document.createElement('div');
|
||||
modalContainer.className = 'cwd-modal-container';
|
||||
this.elements.root.appendChild(modalContainer);
|
||||
}
|
||||
|
||||
this.modal = new AdminAuthModal(modalContainer, {
|
||||
onCancel: () => {
|
||||
this.modal.destroy();
|
||||
this.modal = null;
|
||||
},
|
||||
onSubmit: async (key) => {
|
||||
if (this.props.onVerifyAdmin) {
|
||||
await this.props.onVerifyAdmin(key);
|
||||
auth.saveToken(key);
|
||||
this.modal.destroy();
|
||||
this.modal = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
this.modal.render();
|
||||
}
|
||||
}
|
||||
285
docs/widget/src/components/CommentItem.js
Normal file
285
docs/widget/src/components/CommentItem.js
Normal 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 isPinned = typeof comment.priority === 'number' && comment.priority > 1;
|
||||
const isReplying = this.props.replyingTo === comment.id;
|
||||
const isAdmin = adminEmail && adminBadge && 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.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 ? [
|
||||
this.createTextElement('span', `${adminBadge}`, 'cwd-admin-badge')
|
||||
] : []),
|
||||
...(isPinned ? [
|
||||
this.createTextElement('span', '置顶', 'cwd-pin-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.created), '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.name,
|
||||
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.name,
|
||||
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() {
|
||||
if (this.props.onReply) {
|
||||
this.props.onReply(this.props.comment.id);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
239
docs/widget/src/components/CommentList.js
Normal file
239
docs/widget/src/components/CommentList.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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;
|
||||
// 清空容器
|
||||
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) => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
169
docs/widget/src/components/Component.js
Normal file
169
docs/widget/src/components/Component.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
docs/widget/src/components/Loading.js
Normal file
45
docs/widget/src/components/Loading.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
docs/widget/src/components/Pagination.js
Normal file
124
docs/widget/src/components/Pagination.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
194
docs/widget/src/components/ReplyEditor.js
Normal file
194
docs/widget/src/components/ReplyEditor.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user