feat(widget): 新增评论组件核心功能及开发环境配置
新增评论组件核心功能,包括评论列表、分页、回复、表单验证等功能 添加开发环境配置,包括Vite构建配置、样式变量和工具函数 实现管理员验证功能,支持本地存储加密 添加组件基础类及常用组件如加载状态、分页、模态框等 配置文档站点构建流程,支持widget独立构建和集成
This commit is contained in:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user