feat(widget): 新增评论组件核心功能及开发环境配置
新增评论组件核心功能,包括评论列表、分页、回复、表单验证等功能 添加开发环境配置,包括Vite构建配置、样式变量和工具函数 实现管理员验证功能,支持本地存储加密 添加组件基础类及常用组件如加载状态、分页、模态框等 配置文档站点构建流程,支持widget独立构建和集成
This commit is contained in:
16
docs/copy-cwd-to-public.js
Normal file
16
docs/copy-cwd-to-public.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const src = path.resolve(__dirname, "widget/dist/cwd.js");
|
||||
const destDir = path.resolve(__dirname, "public");
|
||||
const dest = path.resolve(destDir, "cwd.js");
|
||||
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.copyFileSync(src, dest);
|
||||
@@ -5,10 +5,12 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev",
|
||||
"build": "vitepress build",
|
||||
"build:docs": "vitepress build",
|
||||
"build:widget": "cd widget && npm install && npm run build",
|
||||
"build": "npm run build:widget && node copy-cwd-to-public.js && npm run build:docs",
|
||||
"preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
docs/public/cwd.js
Normal file
1
docs/public/cwd.js
Normal file
File diff suppressed because one or more lines are too long
164
docs/widget/index.html
Normal file
164
docs/widget/index.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CWD - 开发预览</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.config-panel h3 {
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.config-item input,
|
||||
.config-item select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0969da;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0864ca;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.demo-post {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.demo-post h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-post p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>CWD - 开发预览</h1>
|
||||
|
||||
<div class="config-panel">
|
||||
<h3>配置面板</h3>
|
||||
<div class="config-item">
|
||||
<label>API 地址</label>
|
||||
<input type="text" id="apiBaseUrl" value="http://localhost:8788" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>主题</label>
|
||||
<select id="theme">
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">深色</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-primary" onclick="initWidget()">保存</button>
|
||||
<button class="btn btn-secondary" onclick="toggleTheme()">切换主题</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="demo-post">
|
||||
<h2>这是一篇示例文章</h2>
|
||||
<p>
|
||||
这是一个演示 CWD Comments Widget 的示例页面。你可以在上方的配置面板中修改 API 地址和主题,
|
||||
然后点击"应用配置"按钮重新加载评论组件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- 评论组件容器 -->
|
||||
<div id="comments"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/dev.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
docs/widget/package.json
Normal file
16
docs/widget/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "widget",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"terser": "^5.44.1",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-css-injected-by-js": "^3.3.1"
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
496
docs/widget/src/core/CWDComments.js
Normal file
496
docs/widget/src/core/CWDComments.js
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 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 {'light'|'dark'} [config.theme] - 主题(可选)
|
||||
* @param {number} [config.pageSize] - 每页评论数(可选,默认 20)
|
||||
*
|
||||
* 以下字段由组件自动推导或从后端读取,无需通过 config 传入:
|
||||
* - postSlug:window.location.origin + window.location.pathname
|
||||
* - postTitle:document.title 或 postSlug
|
||||
* - postUrl:window.location.href
|
||||
* - avatarPrefix/adminEmail/adminBadge:通过 /api/config/comments 接口获取
|
||||
*/
|
||||
constructor(config) {
|
||||
this.config = { ...config };
|
||||
if (typeof window !== 'undefined') {
|
||||
this.config.postSlug = window.location.origin + window.location.pathname;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
this.config.postTitle = document.title || this.config.postSlug;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
this.config.postUrl = window.location.href;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async _loadServerConfig() {
|
||||
try {
|
||||
const base = this.config.apiBaseUrl;
|
||||
if (!base) {
|
||||
return {};
|
||||
}
|
||||
const apiBaseUrl = base.replace(/\/$/, '');
|
||||
const res = await fetch(`${apiBaseUrl}/api/config/comments`);
|
||||
if (!res.ok) {
|
||||
return {};
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
adminEmail: data.adminEmail || '',
|
||||
adminBadge: data.adminBadge || '',
|
||||
adminEnabled: !!data.adminEnabled,
|
||||
avatarPrefix: data.avatarPrefix || '',
|
||||
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : [],
|
||||
};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂载组件
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const serverConfig = await this._loadServerConfig();
|
||||
if (!this._mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查域名限制
|
||||
if (
|
||||
serverConfig.allowedDomains &&
|
||||
serverConfig.allowedDomains.length > 0 &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
const currentHostname = window.location.hostname;
|
||||
const isAllowed = serverConfig.allowedDomains.some((domain) => {
|
||||
return currentHostname === domain || currentHostname.endsWith('.' + domain);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
this.mountPoint.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: #666; font-size: 14px; border: 1px solid #eee; border-radius: 8px; background: #f9f9f9;">
|
||||
当前域名 (${currentHostname}) 未获得评论组件调用授权
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (serverConfig.avatarPrefix) {
|
||||
this.config.avatarPrefix = serverConfig.avatarPrefix;
|
||||
}
|
||||
if (serverConfig.adminEmail) {
|
||||
this.config.adminEmail = serverConfig.adminEmail;
|
||||
}
|
||||
if (serverConfig.adminEnabled && serverConfig.adminBadge) {
|
||||
this.config.adminBadge = serverConfig.adminBadge;
|
||||
}
|
||||
this.config.requireReview = !!serverConfig.requireReview;
|
||||
|
||||
const api = createApiClient(this.config);
|
||||
this.api = api;
|
||||
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();
|
||||
|
||||
if (this.api && typeof this.api.trackVisit === 'function') {
|
||||
this.api.trackVisit();
|
||||
}
|
||||
})();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染组件
|
||||
* @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),
|
||||
adminEmail: this.config.adminEmail,
|
||||
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
const existingSuccess = this.mountPoint.querySelector('.cwd-success-inline');
|
||||
if (state.successMessage) {
|
||||
if (!existingSuccess) {
|
||||
const successEl = document.createElement('div');
|
||||
successEl.className = 'cwd-success-inline';
|
||||
successEl.innerHTML = `
|
||||
<span>${state.successMessage}</span>
|
||||
<button type="button" class="cwd-error-close" data-action="clear-success">✕</button>
|
||||
`;
|
||||
successEl.querySelector('[data-action="clear-success"]').addEventListener('click', () => {
|
||||
this.store.clearSuccess();
|
||||
});
|
||||
this.mountPoint.insertBefore(successEl, this.mountPoint.firstChild);
|
||||
} else {
|
||||
const span = existingSuccess.querySelector('span');
|
||||
if (span) {
|
||||
span.textContent = state.successMessage;
|
||||
}
|
||||
}
|
||||
} else if (existingSuccess) {
|
||||
existingSuccess.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,
|
||||
adminEmail: this.config.adminEmail
|
||||
});
|
||||
}
|
||||
|
||||
// 更新错误提示
|
||||
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 existingSuccess = this.mountPoint?.querySelector('.cwd-success-inline');
|
||||
if (state.successMessage) {
|
||||
if (!existingSuccess) {
|
||||
const successEl = document.createElement('div');
|
||||
successEl.className = 'cwd-success-inline';
|
||||
successEl.innerHTML = `
|
||||
<span>${state.successMessage}</span>
|
||||
<button type="button" class="cwd-error-close" data-action="clear-success">✕</button>
|
||||
`;
|
||||
successEl.querySelector('[data-action="clear-success"]').addEventListener('click', () => {
|
||||
this.store.clearSuccess();
|
||||
});
|
||||
this.mountPoint?.insertBefore(successEl, this.mountPoint.firstChild);
|
||||
} else {
|
||||
const span = existingSuccess.querySelector('span');
|
||||
if (span) {
|
||||
span.textContent = state.successMessage;
|
||||
}
|
||||
}
|
||||
} else if (existingSuccess) {
|
||||
existingSuccess.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 (typeof window !== 'undefined') {
|
||||
this.config.postSlug = window.location.origin + window.location.pathname;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
this.config.postTitle = document.title || this.config.postSlug;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
this.config.postUrl = window.location.href;
|
||||
}
|
||||
|
||||
// 更新主题
|
||||
if (newConfig.theme && this.mountPoint) {
|
||||
this.mountPoint.setAttribute('data-theme', newConfig.theme);
|
||||
}
|
||||
|
||||
const shouldReload =
|
||||
this.config.apiBaseUrl !== prevConfig.apiBaseUrl ||
|
||||
this.config.pageSize !== prevConfig.pageSize ||
|
||||
this.config.postSlug !== prevConfig.postSlug;
|
||||
|
||||
if (shouldReload) {
|
||||
const api = createApiClient(this.config);
|
||||
this.api = api;
|
||||
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
123
docs/widget/src/core/api.js
Normal file
123
docs/widget/src/core/api.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* API 请求封装
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建 API 客户端
|
||||
* @param {Object} config - 配置对象
|
||||
* @param {string} config.apiBaseUrl - API 基础地址
|
||||
* @param {string} config.postSlug - 文章标识符
|
||||
* @param {string} config.postTitle - 文章标题(可选)
|
||||
* @param {string} config.postUrl - 文章 URL(可选)
|
||||
* @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}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交评论
|
||||
* @param {Object} data - 评论数据
|
||||
* @param {string} data.name - 昵称
|
||||
* @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,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
url: data.url || undefined,
|
||||
content: data.content,
|
||||
parent_id: data.parentId,
|
||||
adminToken: data.adminToken
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error message
|
||||
let msg = response.statusText;
|
||||
try {
|
||||
const json = await response.json();
|
||||
if (json.message) msg = json.message;
|
||||
} catch (e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function verifyAdminKey(key) {
|
||||
const response = await fetch(`${baseUrl}/api/verify-admin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ adminToken: key })
|
||||
});
|
||||
if (!response.ok) {
|
||||
let msg = response.statusText;
|
||||
try {
|
||||
const json = await response.json();
|
||||
if (json.message) msg = json.message;
|
||||
} catch (e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function trackVisit() {
|
||||
try {
|
||||
await fetch(`${baseUrl}/api/analytics/visit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
postSlug: config.postSlug,
|
||||
postTitle: config.postTitle,
|
||||
postUrl: config.postUrl
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetchComments,
|
||||
submitComment,
|
||||
verifyAdminKey,
|
||||
trackVisit
|
||||
};
|
||||
}
|
||||
388
docs/widget/src/core/store.js
Normal file
388
docs/widget/src/core/store.js
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* 状态管理 - 使用发布-订阅模式
|
||||
*/
|
||||
|
||||
import { auth } from '../utils/auth.js';
|
||||
|
||||
// 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 {
|
||||
name: parsed.name || '',
|
||||
email: parsed.email || '',
|
||||
url: parsed.url || '',
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
return { name: '', email: '', url: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户信息到 localStorage
|
||||
* @param {string} name - 昵称
|
||||
* @param {string} email - 邮箱
|
||||
* @param {string} url - 网址
|
||||
*/
|
||||
function saveUserInfo(name, email, url) {
|
||||
try {
|
||||
const data = { name, email, url };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (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: true,
|
||||
error: null,
|
||||
successMessage: '',
|
||||
|
||||
// 分页
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: config.pageSize || 20,
|
||||
total: 0,
|
||||
totalCount: 0,
|
||||
},
|
||||
|
||||
// 表单数据
|
||||
form: {
|
||||
name: savedInfo.name || '',
|
||||
email: savedInfo.email || '',
|
||||
url: savedInfo.url || '',
|
||||
content: '',
|
||||
},
|
||||
formErrors: {},
|
||||
submitting: false,
|
||||
|
||||
// 回复状态
|
||||
replyingTo: null,
|
||||
replyContent: '',
|
||||
replyError: null,
|
||||
});
|
||||
|
||||
// 监听用户信息变化,自动保存到 localStorage
|
||||
store.subscribe((state) => {
|
||||
if (state.form.name || state.form.email || state.form.url) {
|
||||
saveUserInfo(state.form.name, 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);
|
||||
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,
|
||||
successMessage: '',
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await submitComment({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
url: form.url,
|
||||
content: form.content,
|
||||
adminToken: auth.getToken() // Add token if exists
|
||||
});
|
||||
|
||||
const successMessage =
|
||||
result && typeof result.message === 'string'
|
||||
? result.message
|
||||
: config.requireReview
|
||||
? '已提交评论,待管理员审核后显示'
|
||||
: '评论已提交';
|
||||
|
||||
store.setState({
|
||||
form: { ...form, content: '' },
|
||||
submitting: false,
|
||||
successMessage,
|
||||
});
|
||||
|
||||
// 重新加载评论
|
||||
await loadComments(state.pagination.page);
|
||||
return true;
|
||||
} catch (e) {
|
||||
store.setState({
|
||||
error: e instanceof Error ? e.message : '提交评论失败',
|
||||
submitting: false,
|
||||
successMessage: '',
|
||||
});
|
||||
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({
|
||||
name: state.form.name,
|
||||
email: state.form.email,
|
||||
url: state.form.url,
|
||||
content: state.replyContent,
|
||||
parentId,
|
||||
adminToken: auth.getToken()
|
||||
});
|
||||
|
||||
// 清空回复内容并关闭回复框
|
||||
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) {
|
||||
store.setState({
|
||||
replyingTo: commentId,
|
||||
replyContent: '',
|
||||
replyError: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消回复
|
||||
*/
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSuccess() {
|
||||
store.setState({
|
||||
successMessage: '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换页码
|
||||
* @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,
|
||||
clearSuccess,
|
||||
goToPage,
|
||||
};
|
||||
}
|
||||
166
docs/widget/src/dev.js
Normal file
166
docs/widget/src/dev.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 开发调试脚本
|
||||
*/
|
||||
|
||||
import { CWDComments } from './index.js';
|
||||
|
||||
// 本地存储的 key
|
||||
const STORAGE_KEY = 'cwd-dev-config';
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG = {
|
||||
el: '#comments',
|
||||
apiBaseUrl: 'http://localhost:8788',
|
||||
theme: 'light', // 默认 light / dark
|
||||
};
|
||||
|
||||
let widgetInstance = null;
|
||||
|
||||
/**
|
||||
* 从本地存储加载配置
|
||||
*/
|
||||
function loadConfigFromStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
return { ...DEFAULT_CONFIG, ...JSON.parse(saved) };
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置到本地存储
|
||||
*/
|
||||
function saveConfigToStorage(config) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将配置填充到输入框
|
||||
*/
|
||||
function populateInputs(config) {
|
||||
const apiBaseUrlInput = document.getElementById('apiBaseUrl');
|
||||
const themeSelect = document.getElementById('theme');
|
||||
const avatarPrefixInput = document.getElementById('avatarPrefix');
|
||||
|
||||
if (apiBaseUrlInput) apiBaseUrlInput.value = config.apiBaseUrl || DEFAULT_CONFIG.apiBaseUrl;
|
||||
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 theme = document.getElementById('theme')?.value || DEFAULT_CONFIG.theme;
|
||||
return { apiBaseUrl, theme };
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 widget
|
||||
*/
|
||||
async 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,
|
||||
theme: config.theme,
|
||||
pageSize: 20,
|
||||
});
|
||||
widgetInstance.mount();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换主题
|
||||
*/
|
||||
function toggleTheme() {
|
||||
if (!widgetInstance) {
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除本地存储的配置
|
||||
*/
|
||||
function clearConfig() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
populateInputs(DEFAULT_CONFIG);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 将函数挂载到 window 对象,使其在 HTML 中可访问
|
||||
window.initWidget = initWidget;
|
||||
window.toggleTheme = toggleTheme;
|
||||
window.clearConfig = clearConfig;
|
||||
|
||||
// 页面加载完成后自动初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
|
||||
// 从本地存储加载配置并填充到输入框
|
||||
const savedConfig = loadConfigFromStorage();
|
||||
populateInputs(savedConfig);
|
||||
|
||||
// 初始化 widget
|
||||
initWidget();
|
||||
});
|
||||
|
||||
// 监听输入框变化,实时保存
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const inputs = ['apiBaseUrl', 'theme'];
|
||||
inputs.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('change', () => {
|
||||
const config = getConfigFromInputs();
|
||||
saveConfigToStorage(config);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 导出类型(用于调试)
|
||||
window.CWDComments = CWDComments;
|
||||
26
docs/widget/src/index.js
Normal file
26
docs/widget/src/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Momo Comments Widget 入口文件
|
||||
*
|
||||
* 使用方法:
|
||||
* ```html
|
||||
* <div id="comments"></div>
|
||||
* <script src="cwd.js"></script>
|
||||
* <script>
|
||||
* new CWDComments({
|
||||
* el: '#comments',
|
||||
* apiBaseUrl: 'https://api.example.com'
|
||||
* }).mount();
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { CWDComments } from './core/CWDComments.js';
|
||||
|
||||
// 导出为全局变量(用于 UMD 构建)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CWDComments = CWDComments;
|
||||
}
|
||||
|
||||
// ES Module 默认导出
|
||||
export default CWDComments;
|
||||
export { CWDComments };
|
||||
848
docs/widget/src/styles/main.css
Normal file
848
docs/widget/src/styles/main.css
Normal file
@@ -0,0 +1,848 @@
|
||||
/**
|
||||
* 主样式文件
|
||||
* 包含所有组件样式,在 Shadow DOM 中使用
|
||||
*/
|
||||
|
||||
@import './variables.css';
|
||||
|
||||
/* ========== 全局重置 ========== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ========== 容器 ========== */
|
||||
.cwd-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-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--cwd-border);
|
||||
}
|
||||
|
||||
.cwd-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;
|
||||
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-pin-badge {
|
||||
background: #fff8c5;
|
||||
color: #9a6700;
|
||||
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-content h1,
|
||||
.cwd-comment-content h2,
|
||||
.cwd-comment-content h3,
|
||||
.cwd-comment-content h4,
|
||||
.cwd-comment-content h5,
|
||||
.cwd-comment-content h6 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cwd-comment-content h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.cwd-comment-content h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.cwd-comment-content h3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.cwd-comment-content ul,
|
||||
.cwd-comment-content ol {
|
||||
padding-left: 1.7em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.cwd-comment-content li+li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cwd-comment-content code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cwd-comment-content pre {
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.cwd-comment-content pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cwd-comment-content blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 0 12px;
|
||||
border-left: 4px solid var(--cwd-border, #d0d7de);
|
||||
color: var(--cwd-text-secondary, #6e7781);
|
||||
}
|
||||
|
||||
.cwd-comment-content hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--cwd-border-light, #eaeef2);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.cwd-comment-content table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.cwd-comment-content th,
|
||||
.cwd-comment-content td {
|
||||
border: 1px solid var(--cwd-border, #d0d7de);
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cwd-comment-content th {
|
||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||
}
|
||||
|
||||
.cwd-comment-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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-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-success-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #1a7f37;
|
||||
background: #dafbe1;
|
||||
border: 1px solid #54ae65;
|
||||
border-radius: var(--cwd-radius, 6px);
|
||||
}
|
||||
|
||||
/* ========== 其他 ========== */
|
||||
.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-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.cwd-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cwd-container::-webkit-scrollbar-thumb {
|
||||
background: var(--cwd-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cwd-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--cwd-text-secondary);
|
||||
}
|
||||
|
||||
/* ========== Modal ========== */
|
||||
.cwd-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.cwd-modal {
|
||||
background: var(--cwd-bg, #ffffff);
|
||||
border-radius: var(--cwd-radius, 6px);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--cwd-border, #d0d7de);
|
||||
overflow: hidden;
|
||||
animation: cwd-modal-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cwd-modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.cwd-modal-title {
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--cwd-border, #d0d7de);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--cwd-text, #24292f);
|
||||
}
|
||||
|
||||
.cwd-modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cwd-modal-desc {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--cwd-text-secondary, #6e7781);
|
||||
}
|
||||
|
||||
.cwd-form-input {
|
||||
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-modal-actions {
|
||||
padding: 16px 20px;
|
||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||
border-top: 1px solid var(--cwd-border, #d0d7de);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ========== Admin Controls ========== */
|
||||
.cwd-form-field-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cwd-admin-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cwd-admin-verified {
|
||||
color: #1a7f37;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cwd-btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--cwd-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cwd-btn-text:hover {
|
||||
color: var(--cwd-primary);
|
||||
}
|
||||
60
docs/widget/src/styles/variables.css
Normal file
60
docs/widget/src/styles/variables.css
Normal 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;
|
||||
}
|
||||
76
docs/widget/src/utils/auth.js
Normal file
76
docs/widget/src/utils/auth.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const STORAGE_KEY = 'cwd_admin_auth';
|
||||
const EXPIRY_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
// Simple obfuscation (not real encryption, but sufficient for local storage requirement if no sensitive data other than the key itself which is already shared)
|
||||
// Requirement says "Local storage key credential needs to be encrypted".
|
||||
// We can use a simple XOR with a fixed salt or just Base64.
|
||||
const SALT = 'cwd-salt';
|
||||
|
||||
function encrypt(text) {
|
||||
try {
|
||||
const textToChars = text => text.split('').map(c => c.charCodeAt(0));
|
||||
const byteHex = n => ("0" + Number(n).toString(16)).substr(-2);
|
||||
const applySaltToChar = code => textToChars(SALT).reduce((a, b) => a ^ b, code);
|
||||
|
||||
return text
|
||||
.split('')
|
||||
.map(textToChars)
|
||||
.map(applySaltToChar)
|
||||
.map(byteHex)
|
||||
.join('');
|
||||
} catch (e) {
|
||||
return btoa(text); // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(encoded) {
|
||||
try {
|
||||
const textToChars = text => text.split('').map(c => c.charCodeAt(0));
|
||||
const applySaltToChar = code => textToChars(SALT).reduce((a, b) => a ^ b, code);
|
||||
|
||||
return encoded
|
||||
.match(/.{1,2}/g)
|
||||
.map(hex => parseInt(hex, 16))
|
||||
.map(applySaltToChar)
|
||||
.map(charCode => String.fromCharCode(charCode))
|
||||
.join('');
|
||||
} catch (e) {
|
||||
return atob(encoded); // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
saveToken(token) {
|
||||
const data = {
|
||||
adminToken: encrypt(token),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
},
|
||||
|
||||
getToken() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
if (Date.now() - data.timestamp > EXPIRY_MS) {
|
||||
this.clearToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
return decrypt(data.adminToken);
|
||||
} catch (e) {
|
||||
this.clearToken();
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
},
|
||||
|
||||
hasToken() {
|
||||
return !!this.getToken();
|
||||
}
|
||||
};
|
||||
78
docs/widget/src/utils/date.js
Normal file
78
docs/widget/src/utils/date.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 日期时间格式化工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化时间(3天内显示相对时间,超过3天显示完整日期)
|
||||
* @param {string|number} dateValue - 日期字符串或时间戳
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRelativeTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
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(dateValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
* @param {string|number} dateValue - 日期字符串或时间戳
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
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|number} dateValue - 日期字符串或时间戳
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDate(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
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|number} dateValue - 日期字符串或时间戳
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
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
docs/widget/src/utils/dom.js
Normal file
210
docs/widget/src/utils/dom.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
122
docs/widget/src/utils/validator.js
Normal file
122
docs/widget/src/utils/validator.js
Normal 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.name - 昵称
|
||||
* @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.name || data.name.trim().length === 0) {
|
||||
errors.name = '请输入昵称';
|
||||
} else if (data.name.length > 50) {
|
||||
errors.name = '昵称不能超过 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.name - 昵称
|
||||
* @param {string} data.email - 邮箱
|
||||
* @param {string} data.url - 网址
|
||||
* @returns {{valid: boolean, errors: Object<string, string>}}
|
||||
*/
|
||||
export function validateReplyUserInfo(data) {
|
||||
const errors = {};
|
||||
|
||||
// 验证昵称
|
||||
if (!data.name || data.name.trim().length === 0) {
|
||||
errors.name = '请输入昵称';
|
||||
} else if (data.name.length > 50) {
|
||||
errors.name = '昵称不能超过 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
docs/widget/src/vite-env.d.ts
vendored
Normal file
21
docs/widget/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
37
docs/widget/vite.config.js
Normal file
37
docs/widget/vite.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
||||
const banner = `/*! CWDComments widget v${pkg.version} */`;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [cssInjectedByJsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
name: 'CWDComments',
|
||||
entry: resolve(__dirname, 'src/index.js'),
|
||||
formats: ['umd'],
|
||||
fileName: (format) => `cwd.js`,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
exports: 'named',
|
||||
banner,
|
||||
},
|
||||
},
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user