You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

973 lines
36 KiB

/**
* Admin 后台管理系统 JavaScript
* 提供通用的交互功能和工具函数
*/
(function() {
'use strict';
// 通用工具函数
const AdminUtils = {
/**
* 显示Toast消息
* @param {string} type - 消息类型 (success, error, warning, info)
* @param {string} message - 消息内容
* @param {number} duration - 显示时长(毫秒)
*/
showToast: function(type, message, duration = 3000) {
// 移除现有的toast
const existingToast = document.querySelector('.admin-toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `admin-toast toast-${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close" aria-label="关闭">×</button>
`;
document.body.appendChild(toast);
// 自动消失
setTimeout(() => {
this.hideToast(toast);
}, duration);
// 点击关闭
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this.hideToast(toast);
});
}
},
/**
* 隐藏Toast消息
* @param {HTMLElement} toast - Toast元素
*/
hideToast: function(toast) {
if (toast && toast.parentNode) {
toast.style.opacity = '0';
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}
},
/**
* 确认对话框
* @param {string} message - 确认消息
* @param {string} title - 对话框标题
* @returns {boolean} 用户确认结果
*/
confirm: function(message, title = '确认') {
return confirm(`${title}\n\n${message}`);
},
/**
* 发送AJAX请求
* @param {string} url - 请求URL
* @param {object} options - 请求选项
* @returns {Promise} 请求Promise
*/
ajax: function(url, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin'
};
const mergedOptions = Object.assign(defaultOptions, options);
if (mergedOptions.body && typeof mergedOptions.body === 'object') {
mergedOptions.body = JSON.stringify(mergedOptions.body);
}
return fetch(url, mergedOptions)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.catch(error => {
console.error('AJAX请求失败:', error);
throw error;
});
},
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} 防抖后的函数
*/
debounce: function(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
},
/**
* 格式化日期
* @param {Date|string} date - 日期对象或字符串
* @param {string} format - 格式类型 (date, time, datetime)
* @returns {string} 格式化后的日期字符串
*/
formatDate: function(date, format = 'datetime') {
const d = new Date(date);
if (isNaN(d.getTime())) {
return '无效日期';
}
const options = {
date: { year: 'numeric', month: '2-digit', day: '2-digit' },
time: { hour: '2-digit', minute: '2-digit' },
datetime: {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}
};
return d.toLocaleString('zh-CN', options[format] || options.datetime);
},
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>} 复制是否成功
*/
copyToClipboard: function(text) {
if (navigator.clipboard) {
return navigator.clipboard.writeText(text)
.then(() => {
this.showToast('success', '已复制到剪贴板');
return true;
})
.catch(err => {
console.error('复制失败:', err);
return this.fallbackCopyTextToClipboard(text);
});
} else {
return Promise.resolve(this.fallbackCopyTextToClipboard(text));
}
},
/**
* 降级复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {boolean} 复制是否成功
*/
fallbackCopyTextToClipboard: function(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
this.showToast('success', '已复制到剪贴板');
} else {
this.showToast('error', '复制失败,请手动复制');
}
return successful;
} catch (err) {
console.error('降级复制失败:', err);
this.showToast('error', '复制失败,请手动复制');
return false;
} finally {
document.body.removeChild(textArea);
}
}
};
// 初始化函数
const AdminApp = {
/**
* 初始化应用
*/
init: function() {
this.initDropdowns();
this.initMobileNav();
this.initToasts();
this.initFormValidation();
this.initTableActions();
this.initSearch();
this.initArticleEditor();
},
/**
* 初始化下拉菜单
*/
initDropdowns: function() {
document.addEventListener('click', (e) => {
// 关闭所有下拉菜单
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
if (!dropdown.contains(e.target)) {
dropdown.classList.remove('active');
}
});
});
// 下拉菜单触发器
const dropdownTriggers = document.querySelectorAll('.dropdown-trigger');
dropdownTriggers.forEach(trigger => {
trigger.addEventListener('click', (e) => {
e.stopPropagation();
const dropdown = trigger.closest('.dropdown');
dropdown.classList.toggle('active');
});
});
},
/**
* 初始化移动端导航
*/
initMobileNav: function() {
// 创建移动端菜单按钮
if (window.innerWidth <= 768) {
this.createMobileMenuButton();
}
window.addEventListener('resize', () => {
if (window.innerWidth <= 768) {
this.createMobileMenuButton();
} else {
this.removeMobileMenuButton();
document.body.classList.remove('sidebar-open');
}
});
},
/**
* 创建移动端菜单按钮
*/
createMobileMenuButton: function() {
if (document.querySelector('.mobile-menu-btn')) return;
const button = document.createElement('button');
button.className = 'mobile-menu-btn';
button.innerHTML = '☰';
button.style.cssText = `
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.5rem;
color: #4a5568;
`;
button.addEventListener('click', () => {
document.body.classList.toggle('sidebar-open');
});
const headerLeft = document.querySelector('.admin-header-left');
if (headerLeft) {
headerLeft.insertBefore(button, headerLeft.firstChild);
}
},
/**
* 移除移动端菜单按钮
*/
removeMobileMenuButton: function() {
const button = document.querySelector('.mobile-menu-btn');
if (button) {
button.remove();
}
},
/**
* 初始化现有Toast消息
*/
initToasts: function() {
const existingToasts = document.querySelectorAll('.admin-toast');
existingToasts.forEach(toast => {
setTimeout(() => {
AdminUtils.hideToast(toast);
}, 3000);
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
AdminUtils.hideToast(toast);
});
}
});
},
/**
* 初始化表单验证
*/
initFormValidation: function() {
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
const requiredFields = form.querySelectorAll('[required]');
let isValid = true;
requiredFields.forEach(field => {
if (!field.value.trim()) {
isValid = false;
field.style.borderColor = '#f56565';
// 移除已有的错误提示
const existingError = field.parentNode.querySelector('.field-error');
if (existingError) {
existingError.remove();
}
// 添加错误提示
const error = document.createElement('div');
error.className = 'field-error';
error.style.cssText = 'color: #f56565; font-size: 0.75rem; margin-top: 0.25rem;';
error.textContent = '此字段为必填项';
field.parentNode.appendChild(error);
} else {
field.style.borderColor = '';
const existingError = field.parentNode.querySelector('.field-error');
if (existingError) {
existingError.remove();
}
}
});
if (!isValid) {
e.preventDefault();
AdminUtils.showToast('error', '请填写所有必填字段');
}
});
// 实时验证
const requiredFields = form.querySelectorAll('[required]');
requiredFields.forEach(field => {
field.addEventListener('blur', () => {
if (!field.value.trim()) {
field.style.borderColor = '#f56565';
} else {
field.style.borderColor = '';
const existingError = field.parentNode.querySelector('.field-error');
if (existingError) {
existingError.remove();
}
}
});
});
});
},
/**
* 初始化表格操作
*/
initTableActions: function() {
// 表格行点击
const tableRows = document.querySelectorAll('tbody tr');
tableRows.forEach(row => {
row.addEventListener('click', (e) => {
// 如果点击的是按钮或链接,不执行行点击事件
if (e.target.closest('button, a, select, input')) {
return;
}
// 高亮当前行
tableRows.forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
});
});
// 状态选择器
const statusSelects = document.querySelectorAll('.status-select');
statusSelects.forEach(select => {
select.addEventListener('change', (e) => {
e.stopPropagation();
});
});
},
/**
* 初始化搜索功能
*/
initSearch: function() {
const searchInputs = document.querySelectorAll('.search-input');
searchInputs.forEach(input => {
// 搜索防抖
const debouncedSearch = AdminUtils.debounce((value) => {
if (value.length >= 2 || value.length === 0) {
// 可以在这里添加实时搜索功能
console.log('搜索:', value);
}
}, 300);
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
// 回车搜索
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.target.closest('form').submit();
}
});
});
},
/**
* 初始化文章编辑器增强功能
*/
initArticleEditor: function() {
const titleInput = document.getElementById('title');
const slugInput = document.getElementById('slug');
const contentTextarea = document.getElementById('content');
if (titleInput && slugInput) {
// 自动生成slug
titleInput.addEventListener('input', AdminUtils.debounce((e) => {
if (!slugInput.value.trim() || slugInput.dataset.autoGenerated === 'true') {
const slug = this.generateSlug(e.target.value);
slugInput.value = slug;
slugInput.dataset.autoGenerated = 'true';
}
}, 300));
// 手动编辑slug时停止自动生成
slugInput.addEventListener('input', () => {
slugInput.dataset.autoGenerated = 'false';
});
}
if (contentTextarea) {
// 添加工具栏
this.addEditorToolbar(contentTextarea);
// Tab键支持
contentTextarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = contentTextarea.selectionStart;
const end = contentTextarea.selectionEnd;
const value = contentTextarea.value;
contentTextarea.value = value.substring(0, start) + ' ' + value.substring(end);
contentTextarea.selectionStart = contentTextarea.selectionEnd = start + 4;
}
});
}
// 初始化字符计数
this.initCharCounters();
},
/**
* 生成URL别名
*/
generateSlug: function(title) {
return title
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 100);
},
/**
* 添加编辑器工具栏
*/
addEditorToolbar: function(textarea) {
const toolbar = document.createElement('div');
toolbar.className = 'editor-toolbar';
toolbar.innerHTML = `
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" data-action="bold" title="粗体 (Ctrl+B)">💪</button>
<button type="button" class="editor-btn" data-action="italic" title="斜体 (Ctrl+I)">🌯</button>
<button type="button" class="editor-btn" data-action="code" title="代码">💻</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" data-action="h1" title="标题 1">🏆</button>
<button type="button" class="editor-btn" data-action="h2" title="标题 2">🥈</button>
<button type="button" class="editor-btn" data-action="h3" title="标题 3">🥉</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" data-action="ul" title="无序列表">📝</button>
<button type="button" class="editor-btn" data-action="ol" title="有序列表">🔢</button>
<button type="button" class="editor-btn" data-action="quote" title="引用">💬</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" data-action="link" title="链接">🔗</button>
<button type="button" class="editor-btn" data-action="image" title="图片">🖼️</button>
</div>
`;
// 添加样式
const style = document.createElement('style');
style.textContent = `
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-bottom: none;
border-radius: 6px 6px 0 0;
flex-wrap: wrap;
}
.editor-toolbar-group {
display: flex;
gap: 0.25rem;
padding: 0 0.5rem;
border-right: 1px solid #e2e8f0;
}
.editor-toolbar-group:last-child {
border-right: none;
}
.editor-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.editor-btn:hover {
background: #e2e8f0;
border-color: #cbd5e0;
}
.editor-toolbar + textarea {
border-radius: 0 0 6px 6px;
}
`;
if (!document.querySelector('#editor-toolbar-style')) {
style.id = 'editor-toolbar-style';
document.head.appendChild(style);
}
// 插入工具栏
textarea.parentNode.insertBefore(toolbar, textarea);
// 绑定事件
toolbar.addEventListener('click', (e) => {
if (e.target.classList.contains('editor-btn')) {
e.preventDefault();
const action = e.target.dataset.action;
this.executeEditorAction(textarea, action);
}
});
// 键盘快捷键
textarea.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
this.executeEditorAction(textarea, 'bold');
break;
case 'i':
e.preventDefault();
this.executeEditorAction(textarea, 'italic');
break;
}
}
});
},
/**
* 执行编辑器动作
*/
executeEditorAction: function(textarea, action) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const beforeText = textarea.value.substring(0, start);
const afterText = textarea.value.substring(end);
let insertText = '';
let cursorOffset = 0;
switch (action) {
case 'bold':
insertText = `**${selectedText || '粗体文字'}**`;
cursorOffset = selectedText ? 0 : -2;
break;
case 'italic':
insertText = `*${selectedText || '斜体文字'}*`;
cursorOffset = selectedText ? 0 : -1;
break;
case 'code':
insertText = `\`${selectedText || '代码'}\``;
cursorOffset = selectedText ? 0 : -1;
break;
case 'h1':
insertText = `# ${selectedText || '标题 1'}`;
break;
case 'h2':
insertText = `## ${selectedText || '标题 2'}`;
break;
case 'h3':
insertText = `### ${selectedText || '标题 3'}`;
break;
case 'ul':
insertText = `- ${selectedText || '列表项'}`;
break;
case 'ol':
insertText = `1. ${selectedText || '列表项'}`;
break;
case 'quote':
insertText = `> ${selectedText || '引用文字'}`;
break;
case 'link':
const linkText = selectedText || '链接文字';
insertText = `[${linkText}](URL)`;
cursorOffset = -4;
break;
case 'image':
const altText = selectedText || '图片描述';
insertText = `![${altText}](URL)`;
cursorOffset = -4;
break;
}
textarea.value = beforeText + insertText + afterText;
const newCursorPos = start + insertText.length + cursorOffset;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
},
/**
* 初始化字符计数器
*/
initCharCounters: function() {
const fields = [
{ id: 'title', max: 200 },
{ id: 'excerpt', max: 500 },
{ id: 'tags', max: 200 },
{ id: 'slug', max: 100 }
];
fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
this.setupCharCounter(element, field.max);
}
});
},
/**
* 设置字符计数器
*/
setupCharCounter: function(element, maxLength) {
const helpElement = element.nextElementSibling;
if (!helpElement || !helpElement.classList.contains('form-help')) {
return;
}
const originalText = helpElement.textContent;
const updateCounter = () => {
const currentLength = element.value.length;
const remaining = maxLength - currentLength;
if (remaining < 50) {
helpElement.textContent = `${originalText} (还可输入${remaining}字符)`;
helpElement.style.color = remaining < 10 ? '#f56565' : '#ed8936';
} else {
helpElement.textContent = originalText;
helpElement.style.color = '';
}
};
element.addEventListener('input', updateCounter);
updateCounter();
}
};
// 全局删除函数
window.deleteArticle = function(id, title) {
if (AdminUtils.confirm(`确定要删除文章《${title}》吗?此操作不可撤销。`, '删除确认')) {
AdminUtils.ajax(`/admin/articles/${id}`, {
method: 'DELETE'
})
.then(data => {
if (data.success) {
AdminUtils.showToast('success', data.message || '文章删除成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
AdminUtils.showToast('error', data.message || '删除失败');
}
})
.catch(error => {
console.error('删除失败:', error);
AdminUtils.showToast('error', '删除失败,请稍后重试');
});
}
};
// 全局联系信息删除函数
window.deleteContact = function(id, title) {
if (AdminUtils.confirm(`确定要删除联系信息《${title}》吗?此操作不可撤销。`, '删除确认')) {
AdminUtils.ajax(`/admin/contacts/${id}`, {
method: 'DELETE'
})
.then(data => {
if (data.success) {
AdminUtils.showToast('success', data.message || '联系信息删除成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
AdminUtils.showToast('error', data.message || '删除失败');
}
})
.catch(error => {
console.error('删除失败:', error);
AdminUtils.showToast('error', '删除失败,请稍后重试');
});
}
};
// 全局状态更新函数
window.updateContactStatus = function(id, status) {
if (!status) return;
AdminUtils.ajax(`/admin/contacts/${id}/status`, {
method: 'PUT',
body: { status }
})
.then(data => {
if (data.success) {
AdminUtils.showToast('success', data.message || '状态更新成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
AdminUtils.showToast('error', data.message || '状态更新失败');
}
})
.catch(error => {
console.error('状态更新失败:', error);
AdminUtils.showToast('error', '状态更新失败,请稍后重试');
});
};
// 全局复制邮箱函数
window.copyEmail = function(email) {
AdminUtils.copyToClipboard(email || document.querySelector('[data-email]')?.dataset.email || '');
};
// 全局预览文章函数
window.previewArticle = function() {
const titleElement = document.getElementById('title');
const contentElement = document.getElementById('content');
if (!titleElement || !contentElement) {
AdminUtils.showToast('error', '无法找到文章内容');
return;
}
const title = titleElement.value.trim();
const content = contentElement.value.trim();
if (!content) {
AdminUtils.showToast('warning', '请先输入文章内容');
contentElement.focus();
return;
}
// 简单的Markdown预览
const previewWindow = window.open('', '_blank', 'width=800,height=600,scrollbars=yes');
if (!previewWindow) {
AdminUtils.showToast('error', '无法打开预览窗口,请检查浏览器弹窗设置');
return;
}
// 基础的Markdown转换
let htmlContent = content
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>');
previewWindow.document.write(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文章预览 - ${title || '未设置标题'}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.7;
color: #2d3748;
background: #ffffff;
}
h1, h2, h3, h4, h5, h6 {
color: #1a202c;
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
h1 { font-size: 2rem; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
p { margin-bottom: 1rem; }
code {
background: #f7fafc;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
color: #e53e3e;
}
pre {
background: #f7fafc;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
border: 1px solid #e2e8f0;
}
pre code {
background: none;
padding: 0;
color: #2d3748;
}
blockquote {
border-left: 4px solid #4299e1;
padding-left: 1rem;
margin: 1rem 0;
color: #4a5568;
background: #f7fafc;
padding: 1rem;
border-radius: 0.5rem;
}
strong { font-weight: 600; color: #1a202c; }
em { font-style: italic; color: #4a5568; }
.preview-header {
background: #4299e1;
color: white;
padding: 1rem;
margin: -2rem -2rem 2rem -2rem;
border-radius: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.preview-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
.preview-close:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
</head>
<body>
<div class="preview-header">
<h1 class="preview-title">📄 文章预览</h1>
<button class="preview-close" onclick="window.close()">关闭预览</button>
</div>
<h1>${title || '未设置标题'}</h1>
<div class="article-content">${htmlContent}</div>
<script>
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.close();
}
});
</script>
</body>
</html>
`);
previewWindow.document.close();
// 聚焦到预览窗口
previewWindow.focus();
};
// 全局工具函数
window.AdminUtils = AdminUtils;
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
AdminApp.init();
});
} else {
AdminApp.init();
}
// CSS样式注入(移动端菜单按钮样式)
const style = document.createElement('style');
style.textContent = `
.selected {
background-color: #ebf8ff !important;
}
.field-error {
color: #f56565;
font-size: 0.75rem;
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.admin-sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar-open .admin-sidebar {
transform: translateX(0);
}
.sidebar-open::before {
content: '';
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 40;
}
}
`;
document.head.appendChild(style);
})();