feat(widget): 新增评论组件核心功能及开发环境配置

新增评论组件核心功能,包括评论列表、分页、回复、表单验证等功能
添加开发环境配置,包括Vite构建配置、样式变量和工具函数
实现管理员验证功能,支持本地存储加密
添加组件基础类及常用组件如加载状态、分页、模态框等
配置文档站点构建流程,支持widget独立构建和集成
This commit is contained in:
anghunk
2026-01-22 09:01:11 +08:00
parent f221245c44
commit 6555907156
28 changed files with 7 additions and 7 deletions

View 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();
}
};

View 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}`;
}

View File

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

View File

@@ -0,0 +1,122 @@
/**
* 表单验证工具
*/
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean}
*/
export function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* 验证 URL 格式
* @param {string} url - URL 地址
* @returns {boolean}
*/
export function isValidUrl(url) {
if (!url) return true; // URL 是可选的
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* 验证评论内容
* @param {string} content - 评论内容
* @returns {{valid: boolean, error?: string}}
*/
export function validateCommentContent(content) {
if (!content || content.trim().length === 0) {
return { valid: false, error: '请输入评论内容' };
}
if (content.length > 1000) {
return { valid: false, error: '评论内容不能超过 1000 字' };
}
return { valid: true };
}
/**
* 验证评论表单
* @param {Object} data - 表单数据
* @param {string} data.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
};
}