Browse Source

feat(admin): 实现后台管理系统设计文档及样式

- 新增完整后台管理系统设计文档,涵盖架构、模块、数据模型、
  页面布局、核心功能、技术规范、权限及安全策略
- 设计文章管理模块和联系信息管理模块的功能及数据流程
- 确定控制器、服务、模型及视图层设计方案与文件结构
- 实现后台独立样式文件 admin.css,包含导航栏、侧边栏、
  文章列表、联系信息列表等样式
- 建立响应式布局及现代化管理界面风格,提升用户体验
- 规划单元测试、集成测试及安全测试策略确保系统稳定性
main
谢亚昕 3 months ago
parent
commit
e5e269b8e6
  1. 355
      .qoder/quests/admin-backend-implementation.md
  2. BIN
      database/db.sqlite3
  3. BIN
      database/db.sqlite3-shm
  4. 0
      database/db.sqlite3-wal
  5. BIN
      database/development.sqlite3-shm
  6. BIN
      database/development.sqlite3-wal
  7. 1688
      public/css/admin.css
  8. 973
      public/js/admin.js
  9. 391
      src/controllers/Page/AdminController.js
  10. 1
      src/controllers/Page/ArticleController.js
  11. 34
      src/controllers/Page/BasePageController.js
  12. 31
      src/db/migrations/20250909000000_create_contacts_table.mjs
  13. 154
      src/db/models/ArticleModel.js
  14. 169
      src/db/models/ContactModel.js
  15. 18
      src/services/ArticleService.js
  16. 390
      src/services/ContactService.js
  17. 225
      src/views/admin/articles/create.pug
  18. 251
      src/views/admin/articles/edit.pug
  19. 198
      src/views/admin/articles/index.pug
  20. 158
      src/views/admin/articles/show.pug
  21. 243
      src/views/admin/contacts/index.pug
  22. 229
      src/views/admin/contacts/show.pug
  23. 125
      src/views/admin/dashboard.pug
  24. 128
      src/views/layouts/admin.pug
  25. 8
      src/views/page/articles/article.pug
  26. 2
      src/views/page/index/index.pug

355
.qoder/quests/admin-backend-implementation.md

@ -0,0 +1,355 @@
# Admin后台管理系统设计文档
## 概述
为 koa3-demo 项目设计并实现一个完整的后台管理系统,允许注册用户管理自己的文章并查看联系我们的提交信息。系统采用传统的左侧导航栏布局,不继承现有页面样式,完全独立实现。
### 核心需求
- 注册用户可以对自己的文章进行增删改查操作
- 展示联系表单提交的信息
- 采用Session认证,不使用API接口
- 独立的管理界面,左侧导航栏+右侧内容区域
- 不允许修改其他现有代码
## 架构设计
### 整体架构图
```mermaid
graph TB
A[用户访问 /admin] --> B[AdminController]
B --> C{Session验证}
C -->|未登录| D[跳转登录页]
C -->|已登录| E[后台主界面]
E --> F[文章管理模块]
E --> G[联系信息模块]
F --> H[ArticleService]
G --> I[ContactService]
H --> J[ArticleModel]
I --> K[ContactModel]
J --> L[(Articles表)]
K --> M[(Contacts表)]
```
### 模块架构
```mermaid
classDiagram
class AdminController {
+dashboard()
+articlesIndex()
+articleShow()
+articleCreate()
+articleEdit()
+articleUpdate()
+articleDelete()
+contactsIndex()
+contactShow()
+contactDelete()
}
class ContactModel {
+findAll()
+findById()
+create()
+delete()
+findByDateRange()
}
class ContactService {
+getAllContacts()
+getContactById()
+deleteContact()
+getContactsByDateRange()
}
AdminController --> ContactService
AdminController --> ArticleService
ContactService --> ContactModel
ArticleService --> ArticleModel
```
## 数据模型设计
### 联系信息表 (contacts)
| 字段名 | 类型 | 约束 | 描述 |
|--------|------|------|------|
| id | INTEGER | PRIMARY KEY | 主键ID |
| name | VARCHAR(100) | NOT NULL | 联系人姓名 |
| email | VARCHAR(255) | NOT NULL | 邮箱地址 |
| subject | VARCHAR(255) | NOT NULL | 主题 |
| message | TEXT | NOT NULL | 留言内容 |
| ip_address | VARCHAR(45) | NULL | IP地址 |
| user_agent | TEXT | NULL | 浏览器信息 |
| status | ENUM('unread','read','replied') | DEFAULT 'unread' | 处理状态 |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
### 数据库迁移设计
```sql
-- 创建联系信息表
CREATE TABLE contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
status VARCHAR(20) DEFAULT 'unread',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 创建索引
CREATE INDEX idx_contacts_status ON contacts(status);
CREATE INDEX idx_contacts_created_at ON contacts(created_at);
CREATE INDEX idx_contacts_email ON contacts(email);
```
## 后台界面设计
### 布局结构
```
┌─────────────────────────────────────────────────────┐
│ 顶部导航栏 │
├──────────────┬──────────────────────────────────────┤
│ │ │
│ 左侧导航 │ 主内容区域 │
│ │ │
│ - 仪表盘 │ ┌────────────────────────────────┐ │
│ - 文章管理 │ │ │ │
│ - 所有文章 │ │ 页面内容 │ │
│ - 新建文章 │ │ │ │
│ - 联系信息 │ │ │ │
│ │ └────────────────────────────────┘ │
│ │ │
└──────────────┴──────────────────────────────────────┘
```
### 页面流程图
```mermaid
flowchart TD
A[访问 /admin] --> B{用户已登录?}
B -->|否| C[跳转到登录页]
B -->|是| D[后台仪表盘]
D --> E[文章管理]
D --> F[联系信息管理]
E --> G[文章列表]
E --> H[新建文章]
G --> I[编辑文章]
G --> J[删除文章]
F --> K[联系信息列表]
K --> L[查看详情]
K --> M[删除信息]
C --> N[登录成功] --> D
```
## 核心功能模块
### 1. 文章管理模块
#### 功能列表
- **文章列表**: 显示当前用户的所有文章,支持状态筛选(草稿/已发布)
- **新建文章**: 创建新文章,支持Markdown编辑
- **编辑文章**: 修改现有文章内容
- **删除文章**: 删除指定文章
- **发布/取消发布**: 切换文章发布状态
#### 权限控制
- 用户只能操作自己创建的文章
- 通过 `author` 字段进行权限过滤
#### 数据流程
```mermaid
sequenceDiagram
participant U as 用户
participant AC as AdminController
participant AS as ArticleService
participant AM as ArticleModel
participant DB as 数据库
U->>AC: 访问文章列表
AC->>AS: getUserArticles(userId)
AS->>AM: findByAuthor(userId)
AM->>DB: SELECT * FROM articles WHERE author = userId
DB-->>AM: 返回文章列表
AM-->>AS: 文章数据
AS-->>AC: 处理后的文章列表
AC-->>U: 渲染文章管理页面
```
### 2. 联系信息管理模块
#### 功能列表
- **信息列表**: 显示所有联系表单提交的信息
- **查看详情**: 查看完整的联系信息内容
- **状态管理**: 标记为已读/未读/已回复
- **删除信息**: 删除不需要的联系信息
- **搜索筛选**: 按时间、状态、邮箱等条件筛选
#### 数据流程
```mermaid
sequenceDiagram
participant U as 用户
participant AC as AdminController
participant CS as ContactService
participant CM as ContactModel
participant DB as 数据库
U->>AC: 访问联系信息列表
AC->>CS: getAllContacts()
CS->>CM: findAll()
CM->>DB: SELECT * FROM contacts ORDER BY created_at DESC
DB-->>CM: 返回联系信息列表
CM-->>CS: 联系信息数据
CS-->>AC: 处理后的信息列表
AC-->>U: 渲染联系信息页面
```
## 技术实现规范
### 1. 控制器层设计
**AdminController.js** - 后台管理主控制器
- 继承现有项目架构模式
- 使用session进行用户认证
- 所有路由需要登录权限
### 2. 服务层设计
**ContactService.js** - 联系信息业务逻辑
- 提供联系信息的CRUD操作
- 实现状态管理功能
- 支持分页和搜索
### 3. 数据访问层设计
**ContactModel.js** - 联系信息数据模型
- 实现基础CRUD操作
- 支持条件查询和排序
- 与现有模型保持一致的设计模式
### 4. 视图层设计
**布局文件**: `admin.pug` - 后台专用布局
- 独立的CSS样式,不继承现有页面
- 响应式左侧导航栏设计
- 现代化的管理界面风格
**页面模板**:
- `admin/dashboard.pug` - 仪表盘首页
- `admin/articles/index.pug` - 文章列表页
- `admin/articles/create.pug` - 新建文章页
- `admin/articles/edit.pug` - 编辑文章页
- `admin/contacts/index.pug` - 联系信息列表页
- `admin/contacts/show.pug` - 联系信息详情页
### 5. 路由设计
```
/admin GET - 后台首页(仪表盘)
/admin/articles GET - 文章列表
/admin/articles/create GET - 新建文章页面
/admin/articles POST - 创建文章
/admin/articles/:id GET - 查看文章详情
/admin/articles/:id/edit GET - 编辑文章页面
/admin/articles/:id PUT - 更新文章
/admin/articles/:id DELETE - 删除文章
/admin/contacts GET - 联系信息列表
/admin/contacts/:id GET - 联系信息详情
/admin/contacts/:id DELETE - 删除联系信息
/admin/contacts/:id/status PUT - 更新联系信息状态
```
## 安全考虑
### 1. 权限控制
- 所有后台路由需要用户登录
- 文章操作权限验证:用户只能操作自己的文章
- 联系信息管理:所有登录用户都可查看
### 2. 数据验证
- 服务端表单验证
- XSS防护:模板自动转义
- CSRF保护:利用现有session机制
### 3. 操作日志
- 记录重要操作(删除文章、删除联系信息)
- 利用现有logger系统
## 集成方案
### 1. 现有系统集成
- **联系表单增强**: 修改BasePageController中的contactPost方法,将数据存储到数据库
- **用户认证复用**: 利用现有session认证机制
- **数据库集成**: 使用现有Knex.js配置和迁移系统
### 2. 不影响现有功能
- 新增模块独立部署在 `/admin` 路径下
- 不修改现有控制器、服务和模型
- 独立的样式文件,避免样式冲突
## 文件结构
```
src/
├── controllers/
│ └── Page/
│ └── AdminController.js # 后台管理控制器
├── services/
│ └── ContactService.js # 联系信息服务
├── db/
│ ├── models/
│ │ └── ContactModel.js # 联系信息模型
│ └── migrations/
│ └── xxxx_create_contacts_table.mjs # 联系表迁移文件
├── views/
│ ├── layouts/
│ │ └── admin.pug # 后台布局模板
│ └── admin/
│ ├── dashboard.pug # 仪表盘
│ ├── articles/
│ │ ├── index.pug # 文章列表
│ │ ├── create.pug # 新建文章
│ │ └── edit.pug # 编辑文章
│ └── contacts/
│ ├── index.pug # 联系信息列表
│ └── show.pug # 联系信息详情
└── public/
├── css/
│ └── admin.css # 后台专用样式
└── js/
└── admin.js # 后台专用脚本
```
## 测试策略
### 单元测试
- ContactModel CRUD操作测试
- ContactService业务逻辑测试
- AdminController路由处理测试
### 集成测试
- 用户权限验证测试
- 文章管理完整流程测试
- 联系信息管理流程测试
### 安全测试
- 权限绕过测试
- XSS攻击防护测试
- 数据验证测试

BIN
database/db.sqlite3

Binary file not shown.

BIN
database/db.sqlite3-shm

Binary file not shown.

0
database/db.sqlite3-wal

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

1688
public/css/admin.css

File diff suppressed because it is too large

973
public/js/admin.js

@ -0,0 +1,973 @@
/**
* 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);
})();

391
src/controllers/Page/AdminController.js

@ -0,0 +1,391 @@
import Router from "../../utils/router.js"
import ArticleService from "../../services/ArticleService.js"
import ContactService from "../../services/ContactService.js"
import { logger } from "../../logger.js"
import CommonError from "../../utils/error/CommonError.js"
/**
* 后台管理控制器
* 负责处理后台管理相关的页面和操作
*/
class AdminController {
constructor() {
this.articleService = new ArticleService()
this.contactService = new ContactService()
}
/**
* 后台首页仪表盘
*/
async dashboard(ctx) {
try {
// 获取统计数据
const [contactStats, userArticles] = await Promise.all([
this.contactService.getContactStats(),
this.articleService.getUserArticles(ctx.session.user.id)
]);
// 计算文章统计
const articleStats = {
total: userArticles.length,
published: userArticles.filter(a => a.status === 'published').length,
draft: userArticles.filter(a => a.status === 'draft').length
};
// 获取最近的联系信息
const recentContacts = await this.contactService.getAllContacts({
page: 1,
limit: 5,
orderBy: 'created_at',
order: 'desc'
});
// 获取最近的文章
const recentArticles = userArticles
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
.slice(0, 5);
return await ctx.render("admin/dashboard", {
contactStats,
articleStats,
recentContacts: recentContacts.contacts,
recentArticles,
title: "后台管理"
}, { layout: "admin" });
} catch (error) {
logger.error(`仪表盘加载失败: ${error.message}`);
throw new CommonError("仪表盘加载失败");
}
}
/**
* 文章管理 - 列表页
*/
async articlesIndex(ctx) {
try {
const { page = 1, status = null, q = null } = ctx.query;
const userId = ctx.session.user.id;
// 使用分页查询,提高性能
const result = await this.articleService.getUserArticlesWithPagination(userId, {
page: parseInt(page),
limit: 10,
status,
keyword: q
});
return await ctx.render("admin/articles/index", {
articles: result.articles,
pagination: result.pagination,
filters: { status, q },
title: "文章管理"
}, { layout: "admin" });
} catch (error) {
logger.error(`文章列表加载失败: ${error.message}`);
throw new CommonError("文章列表加载失败");
}
}
/**
* 文章管理 - 查看详情
*/
async articleShow(ctx) {
try {
const { id } = ctx.params;
const userId = ctx.session.user.id;
const article = await this.articleService.getArticleById(id);
// 检查权限:只能查看自己的文章
if (+article.author !== +userId) {
throw new CommonError("无权访问此文章");
}
return await ctx.render("admin/articles/show", {
article,
title: `查看文章 - ${article.title}`
}, { layout: "admin" });
} catch (error) {
logger.error(`文章详情加载失败: ${error.message}`);
if (error instanceof CommonError) {
ctx.throw(403, error.message);
}
throw new CommonError("文章详情加载失败");
}
}
/**
* 文章管理 - 新建页面
*/
async articleCreate(ctx) {
return await ctx.render("admin/articles/create", {
title: "新建文章"
}, { layout: "admin" });
}
/**
* 文章管理 - 创建文章
*/
async articleStore(ctx) {
try {
const userId = ctx.session.user.id;
const data = {
...ctx.request.body,
author: userId
};
const article = await this.articleService.createArticle(data);
ctx.session.toast = {
type: "success",
message: "文章创建成功"
};
ctx.redirect(`/admin/articles/${article.id}`);
} catch (error) {
logger.error(`文章创建失败: ${error.message}`);
ctx.session.toast = {
type: "error",
message: error.message || "文章创建失败"
};
ctx.redirect("/admin/articles/create");
}
}
/**
* 文章管理 - 编辑页面
*/
async articleEdit(ctx) {
try {
const { id } = ctx.params;
const userId = ctx.session.user.id;
const article = await this.articleService.getArticleById(id);
// 检查权限:只能编辑自己的文章
if (+article.author !== +userId) {
throw new CommonError("无权编辑此文章");
}
return await ctx.render("admin/articles/edit", {
article,
title: `编辑文章 - ${article.title}`
}, { layout: "admin" });
} catch (error) {
logger.error(`文章编辑页面加载失败: ${error.message}`);
if (error instanceof CommonError) {
ctx.throw(403, error.message);
}
throw new CommonError("文章编辑页面加载失败");
}
}
/**
* 文章管理 - 更新文章
*/
async articleUpdate(ctx) {
try {
const { id } = ctx.params;
const userId = ctx.session.user.id;
// 检查权限
const existingArticle = await this.articleService.getArticleById(id);
if (+existingArticle.author !== +userId) {
throw new CommonError("无权编辑此文章");
}
const article = await this.articleService.updateArticle(id, ctx.request.body);
ctx.session.toast = {
type: "success",
message: "文章更新成功"
};
ctx.redirect(`/admin/articles/${article.id}`);
} catch (error) {
logger.error(`文章更新失败: ${error.message}`);
ctx.session.toast = {
type: "error",
message: error.message || "文章更新失败"
};
ctx.redirect(`/admin/articles/${ctx.params.id}/edit`);
}
}
/**
* 文章管理 - 删除文章
*/
async articleDelete(ctx) {
try {
const { id } = ctx.params;
const userId = ctx.session.user.id;
// 检查权限
const article = await this.articleService.getArticleById(id);
if (+article.author !== +userId) {
throw new CommonError("无权删除此文章");
}
await this.articleService.deleteArticle(id);
ctx.session.toast = {
type: "success",
message: "文章删除成功"
};
ctx.body = { success: true, message: "文章删除成功" };
} catch (error) {
logger.error(`文章删除失败: ${error.message}`);
ctx.status = 500;
ctx.body = {
success: false,
message: error.message || "文章删除失败"
};
}
}
/**
* 联系信息管理 - 列表页
*/
async contactsIndex(ctx) {
try {
const {
page = 1,
status = null,
q = null,
limit = 15
} = ctx.query;
let result;
if (q && q.trim()) {
// 搜索模式
result = await this.contactService.searchContacts(q, {
page: parseInt(page),
limit: parseInt(limit),
status
});
} else {
// 普通列表模式
result = await this.contactService.getAllContacts({
page: parseInt(page),
limit: parseInt(limit),
status,
orderBy: 'created_at',
order: 'desc'
});
}
return await ctx.render("admin/contacts/index", {
contacts: result.contacts,
pagination: result.pagination,
filters: { status, q },
title: "联系信息管理"
}, { layout: "admin" });
} catch (error) {
logger.error(`联系信息列表加载失败: ${error.message}`);
throw new CommonError("联系信息列表加载失败");
}
}
/**
* 联系信息管理 - 查看详情
*/
async contactShow(ctx) {
try {
const { id } = ctx.params;
const contact = await this.contactService.getContactById(id);
// 如果是未读状态,自动标记为已读
if (contact.status === 'unread') {
await this.contactService.markAsRead(id);
contact.status = 'read';
}
return await ctx.render("admin/contacts/show", {
contact,
title: `联系信息详情 - ${contact.subject}`
}, { layout: "admin" });
} catch (error) {
logger.error(`联系信息详情加载失败: ${error.message}`);
throw new CommonError("联系信息详情加载失败");
}
}
/**
* 联系信息管理 - 删除
*/
async contactDelete(ctx) {
try {
const { id } = ctx.params;
await this.contactService.deleteContact(id);
ctx.body = { success: true, message: "联系信息删除成功" };
} catch (error) {
logger.error(`联系信息删除失败: ${error.message}`);
ctx.status = 500;
ctx.body = {
success: false,
message: error.message || "联系信息删除失败"
};
}
}
/**
* 联系信息管理 - 更新状态
*/
async contactUpdateStatus(ctx) {
try {
const { id } = ctx.params;
const { status } = ctx.request.body;
await this.contactService.updateContactStatus(id, status);
ctx.body = { success: true, message: "状态更新成功" };
} catch (error) {
logger.error(`联系信息状态更新失败: ${error.message}`);
ctx.status = 500;
ctx.body = {
success: false,
message: error.message || "状态更新失败"
};
}
}
/**
* 创建后台管理路由
*/
static createRoutes() {
const controller = new AdminController();
const router = new Router({
auth: true,
prefix: "/admin",
authFailRedirect: "/login"
});
// 后台首页
router.get("", controller.dashboard.bind(controller));
router.get("/", controller.dashboard.bind(controller));
// 文章管理路由
router.get("/articles", controller.articlesIndex.bind(controller));
router.get("/articles/create", controller.articleCreate.bind(controller));
router.post("/articles", controller.articleStore.bind(controller));
router.get("/articles/:id", controller.articleShow.bind(controller));
router.get("/articles/:id/edit", controller.articleEdit.bind(controller));
router.put("/articles/:id", controller.articleUpdate.bind(controller));
router.post("/articles/:id", controller.articleUpdate.bind(controller)); // 兼容表单提交
router.delete("/articles/:id", controller.articleDelete.bind(controller));
// 联系信息管理路由
router.get("/contacts", controller.contactsIndex.bind(controller));
router.get("/contacts/:id", controller.contactShow.bind(controller));
router.delete("/contacts/:id", controller.contactDelete.bind(controller));
router.put("/contacts/:id/status", controller.contactUpdateStatus.bind(controller));
return router;
}
}
export default AdminController

1
src/controllers/Page/ArticleController.js

@ -43,7 +43,6 @@ class ArticleController {
async show(ctx) {
const { slug } = ctx.params
console.log(slug);
const article = await ArticleModel.findBySlug(slug)

34
src/controllers/Page/BasePageController.js

@ -1,5 +1,6 @@
import Router from "utils/router.js"
import ArticleService from "services/ArticleService.js"
import ContactService from "services/ContactService.js"
import { logger } from "@/logger.js"
/**
@ -9,6 +10,7 @@ import { logger } from "@/logger.js"
class BasePageController {
constructor() {
this.articleService = new ArticleService()
this.contactService = new ContactService()
}
// 首页
@ -63,23 +65,33 @@ class BasePageController {
}
try {
// 这里可以添加邮件发送逻辑或数据库存储逻辑
// 目前只是简单的成功响应和日志记录
logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`)
// 获取用户IP和浏览器信息
const ip_address = ctx.request.ip || ctx.request.header['x-forwarded-for'] || ctx.request.header['x-real-ip'];
const user_agent = ctx.request.header['user-agent'];
// TODO: 可以在这里添加以下功能:
// 1. 发送邮件通知管理员
// 2. 将联系信息存储到数据库
// 3. 发送自动回复邮件给用户
// 存储联系信息到数据库
const contactData = {
name: name.trim(),
email: email.trim(),
subject: subject.trim(),
message: message.trim(),
ip_address,
user_agent,
status: 'unread'
};
await this.contactService.createContact(contactData);
logger.info(`收到联系表单并已存储: ${name} (${email}) - ${subject}`);
ctx.body = {
success: true,
message: "感谢您的留言,我们会尽快回复您!",
}
};
} catch (error) {
logger.error(`联系表单处理失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: "系统错误,请稍后再试" }
logger.error(`联系表单处理失败: ${error.message}`);
ctx.status = 500;
ctx.body = { success: false, message: "系统错误,请稍后再试" };
}
}

31
src/db/migrations/20250909000000_create_contacts_table.mjs

@ -0,0 +1,31 @@
/**
* 联系信息表迁移文件
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = function(knex) {
return knex.schema.createTable('contacts', function (table) {
table.increments('id').primary();
table.string('name', 100).notNullable().comment('联系人姓名');
table.string('email', 255).notNullable().comment('邮箱地址');
table.string('subject', 255).notNullable().comment('主题');
table.text('message').notNullable().comment('留言内容');
table.string('ip_address', 45).nullable().comment('IP地址');
table.text('user_agent').nullable().comment('浏览器信息');
table.string('status', 20).defaultTo('unread').comment('处理状态: unread, read, replied');
table.timestamps(true, true); // created_at, updated_at
// 添加索引
table.index('status', 'idx_contacts_status');
table.index('created_at', 'idx_contacts_created_at');
table.index('email', 'idx_contacts_email');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = function(knex) {
return knex.schema.dropTable('contacts');
};

154
src/db/models/ArticleModel.js

@ -35,6 +35,63 @@ class ArticleModel {
return db("articles").where("author", author).where("status", "published").orderBy("published_at", "desc")
}
static async findByAuthorAll(author) {
return db("articles").where("author", author).orderBy("updated_at", "desc")
}
static async findByAuthorWithPagination(author, options = {}) {
const {
page = 1,
limit = 10,
status = null,
keyword = null,
orderBy = 'updated_at',
order = 'desc'
} = options;
let query = db("articles").where("author", author);
// 状态筛选
if (status) {
query = query.where("status", status);
}
// 关键词搜索
if (keyword && keyword.trim()) {
const searchKeyword = keyword.trim();
query = query.where(function() {
this.where("title", "like", `%${searchKeyword}%`)
.orWhere("content", "like", `%${searchKeyword}%`)
.orWhere("tags", "like", `%${searchKeyword}%`)
.orWhere("description", "like", `%${searchKeyword}%`);
});
}
// 获取总数
const countQuery = query.clone();
const totalResult = await countQuery.count("id as count").first();
const total = totalResult ? parseInt(totalResult.count) : 0;
// 分页查询
const offset = (page - 1) * limit;
const articles = await query
.orderBy(orderBy, order)
.limit(limit)
.offset(offset);
return {
articles,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
};
}
static async findByCategory(category) {
return db("articles").where("category", category).where("status", "published").orderBy("published_at", "desc")
}
@ -95,19 +152,32 @@ class ArticleModel {
excerpt = this.generateExcerpt(data.content)
}
return db("articles")
.insert({
...data,
tags,
slug,
reading_time: readingTime,
excerpt,
status: data.status || "draft",
view_count: 0,
created_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning("*")
// 只插入数据库表中存在的字段
const insertData = {
title: data.title,
content: data.content,
author: data.author,
category: data.category || '',
tags,
keywords: data.keywords || '',
description: data.description || '',
slug,
reading_time: readingTime,
excerpt,
status: data.status || "draft",
view_count: 0,
featured_image: data.featured_image || '',
meta_title: data.meta_title || '',
meta_description: data.meta_description || '',
created_at: db.fn.now(),
updated_at: db.fn.now(),
};
const result = await db("articles")
.insert(insertData)
.returning("*");
return result[0]; // 返回第一个元素而不是数组
}
static async update(id, data) {
@ -150,18 +220,35 @@ class ArticleModel {
publishedAt = db.fn.now()
}
return db("articles")
// 只更新数据库表中存在的字段
const updateData = {
updated_at: db.fn.now(),
};
// 有选择地更新字段
if (data.title !== undefined) updateData.title = data.title;
if (data.content !== undefined) updateData.content = data.content;
if (data.category !== undefined) updateData.category = data.category;
if (data.keywords !== undefined) updateData.keywords = data.keywords;
if (data.description !== undefined) updateData.description = data.description;
if (data.featured_image !== undefined) updateData.featured_image = data.featured_image;
if (data.meta_title !== undefined) updateData.meta_title = data.meta_title;
if (data.meta_description !== undefined) updateData.meta_description = data.meta_description;
if (data.status !== undefined) updateData.status = data.status;
// 处理计算字段
updateData.tags = tags || current.tags;
updateData.slug = slug || current.slug;
updateData.reading_time = readingTime || current.reading_time;
updateData.excerpt = excerpt || current.excerpt;
updateData.published_at = publishedAt || current.published_at;
const result = await db("articles")
.where("id", id)
.update({
...data,
tags: tags || current.tags,
slug: slug || current.slug,
reading_time: readingTime || current.reading_time,
excerpt: excerpt || current.excerpt,
published_at: publishedAt || current.published_at,
updated_at: db.fn.now(),
})
.returning("*")
.update(updateData)
.returning("*");
return result[0]; // 返回第一个元素而不是数组
}
static async delete(id) {
@ -173,29 +260,38 @@ class ArticleModel {
}
static async publish(id) {
return db("articles")
const result = await db("articles")
.where("id", id)
.update({
status: "published",
published_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning("*")
.returning("*");
return result[0]; // 返回第一个元素而不是数组
}
static async unpublish(id) {
return db("articles")
const result = await db("articles")
.where("id", id)
.update({
status: "draft",
published_at: null,
updated_at: db.fn.now(),
})
.returning("*")
.returning("*");
return result[0]; // 返回第一个元素而不是数组
}
static async incrementViewCount(id) {
return db("articles").where("id", id).increment("view_count", 1).returning("*")
const result = await db("articles")
.where("id", id)
.increment("view_count", 1)
.returning("*");
return result[0]; // 返回第一个元素而不是数组
}
static async findByDateRange(startDate, endDate) {

169
src/db/models/ContactModel.js

@ -0,0 +1,169 @@
import db from "../index.js"
class ContactModel {
/**
* 获取所有联系信息
* @param {Object} options - 查询选项
* @param {number} options.page - 页码
* @param {number} options.limit - 每页数量
* @param {string} options.status - 状态筛选
* @param {string} options.orderBy - 排序字段
* @param {string} options.order - 排序方向
* @returns {Promise<Array>} 联系信息列表
*/
static async findAll(options = {}) {
const {
page = 1,
limit = 20,
status = null,
orderBy = 'created_at',
order = 'desc'
} = options;
let query = db("contacts").select("*");
// 状态筛选
if (status) {
query = query.where("status", status);
}
// 排序
query = query.orderBy(orderBy, order);
// 分页
if (page && limit) {
const offset = (page - 1) * limit;
query = query.limit(limit).offset(offset);
}
return query;
}
/**
* 根据ID查找联系信息
* @param {number} id - 联系信息ID
* @returns {Promise<Object|null>} 联系信息对象
*/
static async findById(id) {
return db("contacts").where("id", id).first()
}
/**
* 创建新联系信息
* @param {Object} data - 联系信息数据
* @returns {Promise<Array>} 插入结果
*/
static async create(data) {
return db("contacts").insert({
...data,
created_at: db.fn.now(),
updated_at: db.fn.now(),
}).returning("*")
}
/**
* 更新联系信息
* @param {number} id - 联系信息ID
* @param {Object} data - 更新数据
* @returns {Promise<Array>} 更新结果
*/
static async update(id, data) {
return db("contacts").where("id", id).update({
...data,
updated_at: db.fn.now(),
}).returning("*")
}
/**
* 删除联系信息
* @param {number} id - 联系信息ID
* @returns {Promise<number>} 删除的行数
*/
static async delete(id) {
return db("contacts").where("id", id).del()
}
/**
* 根据邮箱查找联系信息
* @param {string} email - 邮箱地址
* @returns {Promise<Array>} 联系信息列表
*/
static async findByEmail(email) {
return db("contacts").where("email", email).orderBy('created_at', 'desc')
}
/**
* 根据状态查找联系信息
* @param {string} status - 状态
* @returns {Promise<Array>} 联系信息列表
*/
static async findByStatus(status) {
return db("contacts").where("status", status).orderBy('created_at', 'desc')
}
/**
* 根据日期范围查找联系信息
* @param {string} startDate - 开始日期
* @param {string} endDate - 结束日期
* @returns {Promise<Array>} 联系信息列表
*/
static async findByDateRange(startDate, endDate) {
return db("contacts")
.whereBetween('created_at', [startDate, endDate])
.orderBy('created_at', 'desc')
}
/**
* 获取联系信息统计
* @returns {Promise<Object>} 统计信息
*/
static async getStats() {
const total = await db("contacts").count('id as count').first();
const unread = await db("contacts").where('status', 'unread').count('id as count').first();
const read = await db("contacts").where('status', 'read').count('id as count').first();
const replied = await db("contacts").where('status', 'replied').count('id as count').first();
return {
total: parseInt(total.count),
unread: parseInt(unread.count),
read: parseInt(read.count),
replied: parseInt(replied.count)
};
}
/**
* 获取总数用于分页
* @param {Object} options - 查询选项
* @returns {Promise<number>} 总数
*/
static async count(options = {}) {
const { status = null } = options;
let query = db("contacts");
if (status) {
query = query.where("status", status);
}
const result = await query.count('id as count').first();
return parseInt(result.count);
}
/**
* 批量更新状态
* @param {Array} ids - ID数组
* @param {string} status - 新状态
* @returns {Promise<number>} 更新的行数
*/
static async updateStatusBatch(ids, status) {
return db("contacts")
.whereIn("id", ids)
.update({
status,
updated_at: db.fn.now()
});
}
}
export default ContactModel
export { ContactModel }

18
src/services/ArticleService.js

@ -66,6 +66,24 @@ class ArticleService {
}
}
// 获取用户的所有文章(包括草稿)
async getUserArticles(userId) {
try {
return await ArticleModel.findByAuthorAll(userId)
} catch (error) {
throw new CommonError(`获取用户文章失败: ${error.message}`)
}
}
// 分页获取用户文章
async getUserArticlesWithPagination(userId, options = {}) {
try {
return await ArticleModel.findByAuthorWithPagination(userId, options)
} catch (error) {
throw new CommonError(`分页获取用户文章失败: ${error.message}`)
}
}
// 根据分类获取文章
async getArticlesByCategory(category) {
try {

390
src/services/ContactService.js

@ -0,0 +1,390 @@
import ContactModel from "../db/models/ContactModel.js"
import CommonError from "../utils/error/CommonError.js"
class ContactService {
/**
* 获取所有联系信息
* @param {Object} options - 查询选项
* @returns {Promise<Object>} 联系信息列表和分页信息
*/
async getAllContacts(options = {}) {
try {
const {
page = 1,
limit = 20,
status = null,
orderBy = 'created_at',
order = 'desc'
} = options;
// 获取联系信息列表
const contacts = await ContactModel.findAll({
page,
limit,
status,
orderBy,
order
});
// 获取总数
const total = await ContactModel.count({ status });
// 计算分页信息
const totalPages = Math.ceil(total / limit);
const hasNext = page < totalPages;
const hasPrev = page > 1;
return {
contacts,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages,
hasNext,
hasPrev
}
};
} catch (error) {
throw new CommonError(`获取联系信息失败: ${error.message}`);
}
}
/**
* 根据ID获取联系信息
* @param {number} id - 联系信息ID
* @returns {Promise<Object>} 联系信息对象
*/
async getContactById(id) {
try {
if (!id) {
throw new CommonError("联系信息ID不能为空");
}
const contact = await ContactModel.findById(id);
if (!contact) {
throw new CommonError("联系信息不存在");
}
return contact;
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`获取联系信息失败: ${error.message}`);
}
}
/**
* 创建新联系信息
* @param {Object} data - 联系信息数据
* @returns {Promise<Object>} 创建的联系信息
*/
async createContact(data) {
try {
// 验证必需字段
if (!data.name || !data.email || !data.subject || !data.message) {
throw new CommonError("姓名、邮箱、主题和留言内容为必填字段");
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
throw new CommonError("邮箱格式不正确");
}
// 验证字段长度
if (data.name.length > 100) {
throw new CommonError("姓名长度不能超过100字符");
}
if (data.email.length > 255) {
throw new CommonError("邮箱长度不能超过255字符");
}
if (data.subject.length > 255) {
throw new CommonError("主题长度不能超过255字符");
}
const contact = await ContactModel.create(data);
return Array.isArray(contact) ? contact[0] : contact;
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`创建联系信息失败: ${error.message}`);
}
}
/**
* 更新联系信息状态
* @param {number} id - 联系信息ID
* @param {string} status - 新状态
* @returns {Promise<Object>} 更新后的联系信息
*/
async updateContactStatus(id, status) {
try {
if (!id) {
throw new CommonError("联系信息ID不能为空");
}
// 验证状态值
const validStatuses = ['unread', 'read', 'replied'];
if (!validStatuses.includes(status)) {
throw new CommonError("无效的状态值");
}
const contact = await ContactModel.findById(id);
if (!contact) {
throw new CommonError("联系信息不存在");
}
const updatedContact = await ContactModel.update(id, { status });
return Array.isArray(updatedContact) ? updatedContact[0] : updatedContact;
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`更新联系信息状态失败: ${error.message}`);
}
}
/**
* 删除联系信息
* @param {number} id - 联系信息ID
* @returns {Promise<number>} 删除的行数
*/
async deleteContact(id) {
try {
if (!id) {
throw new CommonError("联系信息ID不能为空");
}
const contact = await ContactModel.findById(id);
if (!contact) {
throw new CommonError("联系信息不存在");
}
return await ContactModel.delete(id);
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`删除联系信息失败: ${error.message}`);
}
}
/**
* 根据邮箱获取联系信息
* @param {string} email - 邮箱地址
* @returns {Promise<Array>} 联系信息列表
*/
async getContactsByEmail(email) {
try {
if (!email) {
throw new CommonError("邮箱地址不能为空");
}
return await ContactModel.findByEmail(email);
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`获取联系信息失败: ${error.message}`);
}
}
/**
* 根据状态获取联系信息
* @param {string} status - 状态
* @returns {Promise<Array>} 联系信息列表
*/
async getContactsByStatus(status) {
try {
if (!status) {
throw new CommonError("状态不能为空");
}
const validStatuses = ['unread', 'read', 'replied'];
if (!validStatuses.includes(status)) {
throw new CommonError("无效的状态值");
}
return await ContactModel.findByStatus(status);
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`获取联系信息失败: ${error.message}`);
}
}
/**
* 根据日期范围获取联系信息
* @param {string} startDate - 开始日期
* @param {string} endDate - 结束日期
* @returns {Promise<Array>} 联系信息列表
*/
async getContactsByDateRange(startDate, endDate) {
try {
if (!startDate || !endDate) {
throw new CommonError("开始日期和结束日期不能为空");
}
return await ContactModel.findByDateRange(startDate, endDate);
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`获取联系信息失败: ${error.message}`);
}
}
/**
* 获取联系信息统计
* @returns {Promise<Object>} 统计信息
*/
async getContactStats() {
try {
return await ContactModel.getStats();
} catch (error) {
throw new CommonError(`获取联系信息统计失败: ${error.message}`);
}
}
/**
* 批量更新联系信息状态
* @param {Array} ids - ID数组
* @param {string} status - 新状态
* @returns {Promise<number>} 更新的行数
*/
async updateContactStatusBatch(ids, status) {
try {
if (!Array.isArray(ids) || ids.length === 0) {
throw new CommonError("ID数组不能为空");
}
const validStatuses = ['unread', 'read', 'replied'];
if (!validStatuses.includes(status)) {
throw new CommonError("无效的状态值");
}
return await ContactModel.updateStatusBatch(ids, status);
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`批量更新联系信息状态失败: ${error.message}`);
}
}
/**
* 批量删除联系信息
* @param {Array} ids - ID数组
* @returns {Promise<Object>} 删除结果
*/
async deleteContactsBatch(ids) {
try {
if (!Array.isArray(ids) || ids.length === 0) {
throw new CommonError("ID数组不能为空");
}
const results = [];
const errors = [];
for (const id of ids) {
try {
await this.deleteContact(id);
results.push(id);
} catch (error) {
errors.push({
id,
error: error.message
});
}
}
return {
success: results,
errors,
total: ids.length,
successCount: results.length,
errorCount: errors.length
};
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`批量删除联系信息失败: ${error.message}`);
}
}
/**
* 搜索联系信息
* @param {string} keyword - 搜索关键词
* @param {Object} options - 查询选项
* @returns {Promise<Object>} 搜索结果和分页信息
*/
async searchContacts(keyword, options = {}) {
try {
if (!keyword || keyword.trim() === '') {
return await this.getAllContacts(options);
}
const {
page = 1,
limit = 20,
status = null
} = options;
const searchTerm = keyword.toLowerCase().trim();
// 获取所有联系信息进行搜索
const allContacts = await ContactModel.findAll({ status });
const filteredContacts = allContacts.filter(contact => {
return (
contact.name?.toLowerCase().includes(searchTerm) ||
contact.email?.toLowerCase().includes(searchTerm) ||
contact.subject?.toLowerCase().includes(searchTerm) ||
contact.message?.toLowerCase().includes(searchTerm)
);
});
// 手动分页
const total = filteredContacts.length;
const offset = (page - 1) * limit;
const contacts = filteredContacts.slice(offset, offset + limit);
// 计算分页信息
const totalPages = Math.ceil(total / limit);
const hasNext = page < totalPages;
const hasPrev = page > 1;
return {
contacts,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages,
hasNext,
hasPrev
},
keyword: searchTerm
};
} catch (error) {
if (error instanceof CommonError) throw error;
throw new CommonError(`搜索联系信息失败: ${error.message}`);
}
}
/**
* 标记联系信息为已读
* @param {number} id - 联系信息ID
* @returns {Promise<Object>} 更新后的联系信息
*/
async markAsRead(id) {
return await this.updateContactStatus(id, 'read');
}
/**
* 标记联系信息为已回复
* @param {number} id - 联系信息ID
* @returns {Promise<Object>} 更新后的联系信息
*/
async markAsReplied(id) {
return await this.updateContactStatus(id, 'replied');
}
/**
* 标记联系信息为未读
* @param {number} id - 联系信息ID
* @returns {Promise<Object>} 更新后的联系信息
*/
async markAsUnread(id) {
return await this.updateContactStatus(id, 'unread');
}
}
export default ContactService

225
src/views/admin/articles/create.pug

@ -0,0 +1,225 @@
extends ../../layouts/admin
block $$content
.page-container
//- 页面头部
.page-header
.page-header-left
.breadcrumb
a(href="/admin/articles") 文章管理
span.breadcrumb-separator /
span.breadcrumb-current 新建文章
h1.page-title 新建文章
p.page-subtitle 创建一篇新的文章内容
//- 文章表单
.content-section
form.article-form(method="POST" action="/admin/articles")
.form-grid
//- 左侧主要内容
.form-main
.form-group
label.form-label(for="title") 文章标题 *
input.form-input(
type="text"
id="title"
name="title"
placeholder="请输入文章标题"
required
maxlength="200"
)
.form-help 建议标题长度在10-50字之间
.form-group
label.form-label(for="slug") URL别名
input.form-input(
type="text"
id="slug"
name="slug"
placeholder="自动生成或手动输入"
pattern="[a-z0-9-]+"
maxlength="100"
)
.form-help 用于生成友好的URL,只能包含小写字母、数字和连字符
.form-group
label.form-label(for="excerpt") 文章摘要
textarea.form-textarea(
id="excerpt"
name="excerpt"
placeholder="请输入文章摘要(可选)"
rows="3"
maxlength="500"
)
.form-help 简短描述文章内容,有助于SEO
.form-group
label.form-label(for="content") 文章内容 *
textarea.form-textarea.content-editor(
id="content"
name="content"
placeholder="请输入文章内容,支持Markdown格式"
rows="20"
required
)
.form-help 支持Markdown语法,可直接粘贴Markdown内容
//- 右侧设置面板
.form-sidebar
.sidebar-section
h3.sidebar-title 发布设置
.form-group
label.form-label(for="status") 发布状态
select.form-select(id="status" name="status")
option(value="draft") 📝 保存为草稿
option(value="published") ✅ 立即发布
.form-help 草稿状态下文章不会在前台显示
.form-group
label.form-label(for="category") 文章分类
input.form-input(
type="text"
id="category"
name="category"
placeholder="如:技术、生活、随笔"
maxlength="50"
)
.form-help 用于组织和分类文章
.form-group
label.form-label(for="tags") 文章标签
input.form-input(
type="text"
id="tags"
name="tags"
placeholder="标签1,标签2,标签3"
maxlength="200"
)
.form-help 多个标签用英文逗号分隔
.sidebar-section
h3.sidebar-title 高级设置
.form-group
label.form-label(for="featured_image") 特色图片URL
input.form-input(
type="url"
id="featured_image"
name="featured_image"
placeholder="https://example.com/image.jpg"
)
.form-help 文章封面图片链接
//- 表单按钮
.form-actions
.action-buttons
button.btn.btn-primary(type="submit") 💾 保存文章
a.btn.btn-outline(href="/admin/articles") 取消
button.btn.btn-secondary(type="button" onclick="previewArticle()") 👁️ 预览
block $$scripts
script.
// 自动生成slug
document.getElementById('title').addEventListener('input', function() {
const title = this.value;
const slugInput = document.getElementById('slug');
if (!slugInput.value) {
// 简单的slug生成逻辑
const slug = title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
slugInput.value = slug;
}
});
// 字符计数
function setupCharCounter(inputId, maxLength) {
const input = document.getElementById(inputId);
const help = input.nextElementSibling;
function updateCounter() {
const current = input.value.length;
const remaining = maxLength - current;
const originalText = help.textContent;
if (remaining < 50) {
help.textContent = `${originalText} (还可输入${remaining}字符)`;
help.style.color = remaining < 10 ? '#ff4757' : '#ffa726';
} else {
help.style.color = '';
}
}
input.addEventListener('input', updateCounter);
updateCounter();
}
// 设置字符计数
setupCharCounter('title', 200);
setupCharCounter('excerpt', 500);
setupCharCounter('tags', 200);
// 预览功能
function previewArticle() {
const content = document.getElementById('content').value;
if (!content.trim()) {
alert('请先输入文章内容');
return;
}
// 简单的Markdown预览(实际项目中可以使用更完善的Markdown解析器)
const previewWindow = window.open('', '_blank', 'width=800,height=600');
previewWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>文章预览</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
h1, h2, h3 { color: #333; }
pre { background: #f4f4f4; padding: 10px; border-radius: 4px; }
code { background: #f4f4f4; padding: 2px 4px; border-radius: 2px; }
</style>
</head>
<body>
<h1>${document.getElementById('title').value || '未设置标题'}</h1>
<div>${content.replace(/\n/g, '<br>')}</div>
</body>
</html>
`);
previewWindow.document.close();
}
// 表单验证
document.querySelector('.article-form').addEventListener('submit', function(e) {
const title = document.getElementById('title').value.trim();
const content = document.getElementById('content').value.trim();
if (!title) {
alert('请输入文章标题');
e.preventDefault();
return;
}
if (!content) {
alert('请输入文章内容');
e.preventDefault();
return;
}
if (title.length < 5) {
alert('文章标题至少需要5个字符');
e.preventDefault();
return;
}
if (content.length < 10) {
alert('文章内容至少需要10个字符');
e.preventDefault();
return;
}
});

251
src/views/admin/articles/edit.pug

@ -0,0 +1,251 @@
extends ../../layouts/admin
block $$content
.page-container
//- 页面头部
.page-header
.page-header-left
.breadcrumb
a(href="/admin/articles") 文章管理
span.breadcrumb-separator /
a(href=`/admin/articles/${article.id}`)= article.title
span.breadcrumb-separator /
span.breadcrumb-current 编辑
h1.page-title 编辑文章
p.page-subtitle= `编辑:${article.title}`
//- 文章表单
.content-section
form.article-form(method="POST" action=`/admin/articles/${article.id}`)
input(type="hidden" name="_method" value="PUT")
.form-grid
//- 左侧主要内容
.form-main
.form-group
label.form-label(for="title") 文章标题 *
input.form-input(
type="text"
id="title"
name="title"
value=article.title
placeholder="请输入文章标题"
required
maxlength="200"
)
.form-help 建议标题长度在10-50字之间
.form-group
label.form-label(for="slug") URL别名
input.form-input(
type="text"
id="slug"
name="slug"
value=article.slug || ''
placeholder="自动生成或手动输入"
pattern="[a-z0-9-]+"
maxlength="100"
)
.form-help 用于生成友好的URL,只能包含小写字母、数字和连字符
.form-group
label.form-label(for="excerpt") 文章摘要
textarea.form-textarea(
id="excerpt"
name="excerpt"
placeholder="请输入文章摘要(可选)"
rows="3"
maxlength="500"
)= article.excerpt || ''
.form-help 简短描述文章内容,有助于SEO
.form-group
label.form-label(for="content") 文章内容 *
textarea.form-textarea.content-editor(
id="content"
name="content"
placeholder="请输入文章内容,支持Markdown格式"
rows="20"
required
)= article.content || ''
.form-help 支持Markdown语法,可直接粘贴Markdown内容
//- 右侧设置面板
.form-sidebar
.sidebar-section
h3.sidebar-title 发布设置
.form-group
label.form-label(for="status") 发布状态
select.form-select(id="status" name="status")
option(value="draft" selected=article.status === 'draft') 📝 保存为草稿
option(value="published" selected=article.status === 'published') ✅ 立即发布
.form-help 草稿状态下文章不会在前台显示
.form-group
label.form-label(for="category") 文章分类
input.form-input(
type="text"
id="category"
name="category"
value=article.category || ''
placeholder="如:技术、生活、随笔"
maxlength="50"
)
.form-help 用于组织和分类文章
.form-group
label.form-label(for="tags") 文章标签
input.form-input(
type="text"
id="tags"
name="tags"
value=article.tags || ''
placeholder="标签1,标签2,标签3"
maxlength="200"
)
.form-help 多个标签用英文逗号分隔
.sidebar-section
h3.sidebar-title 高级设置
.form-group
label.form-label(for="featured_image") 特色图片URL
input.form-input(
type="url"
id="featured_image"
name="featured_image"
value=article.featured_image || ''
placeholder="https://example.com/image.jpg"
)
.form-help 文章封面图片链接
.sidebar-section
h3.sidebar-title 文章信息
.info-list
.info-item
.info-label 创建时间
.info-value= new Date(article.created_at).toLocaleString('zh-CN')
.info-item
.info-label 更新时间
.info-value= new Date(article.updated_at).toLocaleString('zh-CN')
if article.view_count
.info-item
.info-label 阅读量
.info-value= article.view_count
//- 表单按钮
.form-actions
.action-buttons
button.btn.btn-primary(type="submit") 💾 更新文章 #{article.id}
a.btn.btn-outline(href=`/admin/articles/${article.id}`) 取消
button.btn.btn-secondary(type="button" onclick="previewArticle()") 👁️ 预览
if article.status === 'published' && article.slug
a.btn.btn-outline(href=`/articles/${article.slug}` target="_blank") 🔗 查看前台
block $$scripts
script.
// 自动生成slug(仅在为空时)
document.getElementById('title').addEventListener('input', function() {
const title = this.value;
const slugInput = document.getElementById('slug');
if (!slugInput.value.trim()) {
// 简单的slug生成逻辑
const slug = title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
slugInput.value = slug;
}
});
// 字符计数
function setupCharCounter(inputId, maxLength) {
const input = document.getElementById(inputId);
const help = input.nextElementSibling;
function updateCounter() {
const current = input.value.length;
const remaining = maxLength - current;
const originalText = help.textContent.split('(')[0].trim();
if (remaining < 50) {
help.textContent = `${originalText} (还可输入${remaining}字符)`;
help.style.color = remaining < 10 ? '#ff4757' : '#ffa726';
} else {
help.textContent = originalText;
help.style.color = '';
}
}
input.addEventListener('input', updateCounter);
updateCounter();
}
// 设置字符计数
setupCharCounter('title', 200);
setupCharCounter('excerpt', 500);
setupCharCounter('tags', 200);
// 预览功能
function previewArticle() {
const content = document.getElementById('content').value;
if (!content.trim()) {
alert('请先输入文章内容');
return;
}
// 简单的Markdown预览
const previewWindow = window.open('', '_blank', 'width=800,height=600');
previewWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>文章预览</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
h1, h2, h3 { color: #333; }
pre { background: #f4f4f4; padding: 10px; border-radius: 4px; }
code { background: #f4f4f4; padding: 2px 4px; border-radius: 2px; }
</style>
</head>
<body>
<h1>${document.getElementById('title').value || '未设置标题'}</h1>
<div>${content.replace(/\n/g, '<br>')}</div>
</body>
</html>
`);
previewWindow.document.close();
}
// 表单验证
document.querySelector('.article-form').addEventListener('submit', function(e) {
const title = document.getElementById('title').value.trim();
const content = document.getElementById('content').value.trim();
if (!title) {
alert('请输入文章标题');
e.preventDefault();
return;
}
if (!content) {
alert('请输入文章内容');
e.preventDefault();
return;
}
if (title.length < 5) {
alert('文章标题至少需要5个字符');
e.preventDefault();
return;
}
if (content.length < 10) {
alert('文章内容至少需要10个字符');
e.preventDefault();
return;
}
});

198
src/views/admin/articles/index.pug

@ -0,0 +1,198 @@
extends ../../layouts/admin
block $$content
.page-container
//- 页面头部
.page-header
.page-header-left
h1.page-title 文章管理
p.page-subtitle 管理您的所有文章内容
.page-header-right
a.btn.btn-primary(href="/admin/articles/create") ✨ 新建文章
//- 筛选和搜索
.page-filters
form.filter-form(method="GET")
.filter-group
label.filter-label 状态筛选:
select.filter-select(name="status" onchange="this.form.submit()")
option(value="" selected=!filters.status) 全部状态
option(value="published" selected=filters.status === 'published') 已发布
option(value="draft" selected=filters.status === 'draft') 草稿
.filter-group
label.filter-label 搜索:
.search-box
input.search-input(
type="text"
name="q"
placeholder="搜索文章标题、内容或标签..."
value=filters.q || ''
)
button.search-btn(type="submit") 🔍
if filters.status || filters.q
a.filter-clear(href="/admin/articles") 清除筛选
//- 文章列表
.content-section
if articles && articles.length > 0
.article-table-container
table.article-table
thead
tr
th.col-title 标题
th.col-status 状态
th.col-category 分类
th.col-date 更新时间
th.col-actions 操作
tbody
each article in articles
tr.article-row
td.col-title
.article-title-cell
h3.article-title
a(href=`/admin/articles/${article.id}`)= article.title
if article.excerpt
p.article-summary= article.excerpt.substring(0, 80) + (article.excerpt.length > 80 ? '...' : '')
if article.tags
.article-tags
each tag in article.tags.split(',')
span.tag= tag.trim()
td.col-status
span.status-badge(class=`status-${article.status}`)
if article.status === 'published'
| ✅ 已发布
else if article.status === 'draft'
| 📝 草稿
else
| ❓ #{article.status}
td.col-category
if article.category
span.category-badge= article.category
else
span.text-muted 未分类
td.col-date
.date-info
.primary-date= new Date(article.updated_at).toLocaleDateString('zh-CN')
.secondary-date= new Date(article.updated_at).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})
td.col-actions
.action-buttons
a.btn.btn-sm.btn-outline(href=`/admin/articles/${article.id}` title="查看详情") 👁️
a.btn.btn-sm.btn-outline(href=`/admin/articles/${article.id}/edit` title="编辑文章") ✏️
if article.status === 'published'
a.btn.btn-sm.btn-outline(href=`/articles/${article.slug}` target="_blank" title="预览文章") 🔗
button.btn.btn-sm.btn-danger(
onclick=`deleteArticle(${article.id}, '${article.title.replace(/'/g, "\\'")}')`
title="删除文章"
) 🗑️
//- 分页导航
if pagination.totalPages > 1
.pagination-container
nav.pagination
if pagination.hasPrev
a.pagination-link(href=`?page=${pagination.page - 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) ‹ 上一页
- var start = Math.max(1, pagination.page - 2)
- var end = Math.min(pagination.totalPages, pagination.page + 2)
if start > 1
a.pagination-link(href=`?page=1${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 1
if start > 2
span.pagination-ellipsis ...
- for (var i = start; i <= end; i++)
if i === pagination.page
span.pagination-link.active= i
else
a.pagination-link(href=`?page=${i}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= i
if end < pagination.totalPages
if end < pagination.totalPages - 1
span.pagination-ellipsis ...
a.pagination-link(href=`?page=${pagination.totalPages}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= pagination.totalPages
if pagination.hasNext
a.pagination-link(href=`?page=${pagination.page + 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 下一页 ›
//- 统计信息
.list-footer
.list-stats
| 共 #{pagination.total} 篇文章
if filters.status || filters.q
|,当前显示筛选结果
else
//- 空状态
.empty-state
.empty-icon 📝
.empty-title 暂无文章
if filters.status || filters.q
.empty-text 没有找到符合筛选条件的文章
a.btn.btn-outline(href="/admin/articles") 查看全部文章
else
.empty-text 您还没有创建任何文章
a.btn.btn-primary(href="/admin/articles/create") 创建第一篇文章
block $$scripts
script.
// 删除文章确认
function deleteArticle(id, title) {
if (confirm(`确定要删除文章《${title}》吗?此操作不可撤销。`)) {
fetch(`/admin/articles/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 显示成功消息
showToast('success', data.message || '文章删除成功');
// 刷新页面或移除行
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || '删除失败');
}
})
.catch(error => {
console.error('删除失败:', error);
showToast('error', '删除失败,请稍后重试');
});
}
}
// 显示提示消息
function showToast(type, message) {
const toast = document.createElement('div');
toast.className = `admin-toast toast-${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close">×</button>
`;
document.body.appendChild(toast);
// 自动消失
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
// 点击关闭
toast.querySelector('.toast-close').addEventListener('click', () => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
});
}

158
src/views/admin/articles/show.pug

@ -0,0 +1,158 @@
extends ../../layouts/admin
block $$content
.page-container
//- 页面头部
.page-header
.page-header-left
.breadcrumb
a(href="/admin/articles") 文章管理
span.breadcrumb-separator /
span.breadcrumb-current= article.title
h1.page-title= article.title
.article-meta
span.meta-item
strong 状态:
span.status-badge(class=`status-${article.status}`)
if article.status === 'published'
| ✅ 已发布
else if article.status === 'draft'
| 📝 草稿
if article.category
span.meta-item
strong 分类:
span.category-badge= article.category
span.meta-item
strong 创建时间:
span= new Date(article.created_at).toLocaleString('zh-CN')
span.meta-item
strong 更新时间:
span= new Date(article.updated_at).toLocaleString('zh-CN')
if article.view_count
span.meta-item
strong 阅读量:
span= article.view_count
.page-header-right
.action-buttons
a.btn.btn-outline(href=`/admin/articles/${article.id}/edit`) ✏️ 编辑
if article.status === 'published' && article.slug
a.btn.btn-outline(href=`/articles/${article.slug}` target="_blank") 🔗 预览
button.btn.btn-danger(
onclick=`deleteArticle(${article.id}, '${article.title.replace(/'/g, "\\'")}')`
) 🗑️ 删除
//- 文章内容
.content-section
.article-view
//- 文章摘要
if article.excerpt
.article-summary-section
h3.section-title 文章摘要
.article-summary= article.excerpt
//- 文章内容
.article-content-section
h3.section-title 文章内容
.article-content
if article.content
!= article.content.replace(/\n/g, '<br>')
else
.empty-content 暂无内容
//- 文章标签
if article.tags
.article-tags-section
h3.section-title 标签
.article-tags
each tag in article.tags.split(',')
span.tag= tag.trim()
//- 文章链接信息
if article.slug
.article-link-section
h3.section-title 文章链接
.link-info
strong 访问链接:
if article.status === 'published'
a.article-link(href=`/articles/${article.slug}` target="_blank")= `/articles/${article.slug}`
else
span.text-muted= `/articles/${article.slug}`
small (草稿状态,暂不可访问)
//- 技术信息
.article-technical-section
h3.section-title 技术信息
.technical-info
.info-grid
.info-item
.info-label ID
.info-value= article.id
.info-item
.info-label Slug
.info-value= article.slug || '未设置'
.info-item
.info-label 作者ID
.info-value= article.author
if article.featured_image
.info-item
.info-label 特色图片
.info-value
a(href=article.featured_image target="_blank")= article.featured_image
block $$scripts
script.
// 删除文章确认
function deleteArticle(id, title) {
if (confirm(`确定要删除文章《${title}》吗?此操作不可撤销。`)) {
fetch(`/admin/articles/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('success', data.message || '文章删除成功');
setTimeout(() => {
window.location.href = '/admin/articles';
}, 1000);
} else {
showToast('error', data.message || '删除失败');
}
})
.catch(error => {
console.error('删除失败:', error);
showToast('error', '删除失败,请稍后重试');
});
}
}
// 显示提示消息
function showToast(type, message) {
const toast = document.createElement('div');
toast.className = `admin-toast toast-${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close">×</button>
`;
document.body.appendChild(toast);
// 自动消失
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
// 点击关闭
toast.querySelector('.toast-close').addEventListener('click', () => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
});
}

243
src/views/admin/contacts/index.pug

@ -0,0 +1,243 @@
extends ../../layouts/admin
block $$content
.page-container
//- 页面头部
.page-header
.page-header-left
h1.page-title 联系信息管理
p.page-subtitle 管理用户提交的联系表单信息
.page-header-right
.stats-summary
span.stat-item
.stat-number= pagination.total
.stat-label 总数
span.stat-item.unread
.stat-number
| #{contacts.filter(c => c.status === 'unread').length}
.stat-label 未读
//- 筛选和搜索
.page-filters
form.filter-form(method="GET")
.filter-group
label.filter-label 状态筛选:
select.filter-select(name="status" onchange="this.form.submit()")
option(value="" selected=!filters.status) 全部状态
option(value="unread" selected=filters.status === 'unread') 📧 未读
option(value="read" selected=filters.status === 'read') 👁️ 已读
option(value="replied" selected=filters.status === 'replied') ✅ 已回复
.filter-group
label.filter-label 搜索:
.search-box
input.search-input(
type="text"
name="q"
placeholder="搜索姓名、邮箱、主题或内容..."
value=filters.q || ''
)
button.search-btn(type="submit") 🔍
if filters.status || filters.q
a.filter-clear(href="/admin/contacts") 清除筛选
//- 联系信息列表
.content-section
if contacts && contacts.length > 0
.contact-table-container
table.contact-table
thead
tr
th.col-contact 联系人信息
th.col-subject 主题
th.col-status 状态
th.col-date 提交时间
th.col-actions 操作
tbody
each contact in contacts
tr.contact-row(class=`status-${contact.status}`)
td.col-contact
.contact-info
.contact-name= contact.name
.contact-email= contact.email
if contact.ip_address
.contact-ip IP: #{contact.ip_address}
td.col-subject
.subject-content
h4.subject-title= contact.subject
.subject-preview= contact.message.substring(0, 80) + (contact.message.length > 80 ? '...' : '')
td.col-status
span.status-badge(class=`status-${contact.status}`)
if contact.status === 'unread'
| 📧 未读
else if contact.status === 'read'
| 👁️ 已读
else if contact.status === 'replied'
| ✅ 已回复
else
| ❓ #{contact.status}
td.col-date
.date-info
.primary-date= new Date(contact.created_at).toLocaleDateString('zh-CN')
.secondary-date= new Date(contact.created_at).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})
td.col-actions
.action-buttons
a.btn.btn-sm.btn-outline(href=`/admin/contacts/${contact.id}` title="查看详情") 👁️
.status-dropdown
select.status-select(onchange=`updateContactStatus(${contact.id}, this.value)`)
option(value="" disabled selected) 状态...
option(value="unread" class=contact.status === 'unread' ? 'disabled' : '') 标记未读
option(value="read" class=contact.status === 'read' ? 'disabled' : '') 标记已读
option(value="replied" class=contact.status === 'replied' ? 'disabled' : '') 标记已回复
button.btn.btn-sm.btn-danger(
onclick=`deleteContact(${contact.id}, '${contact.name} - ${contact.subject}')`
title="删除联系信息"
) 🗑️
//- 分页导航
if pagination.totalPages > 1
.pagination-container
nav.pagination
if pagination.hasPrev
a.pagination-link(href=`?page=${pagination.page - 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) ‹ 上一页
- var start = Math.max(1, pagination.page - 2)
- var end = Math.min(pagination.totalPages, pagination.page + 2)
if start > 1
a.pagination-link(href=`?page=1${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 1
if start > 2
span.pagination-ellipsis ...
- for (var i = start; i <= end; i++)
if i === pagination.page
span.pagination-link.active= i
else
a.pagination-link(href=`?page=${i}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= i
if end < pagination.totalPages
if end < pagination.totalPages - 1
span.pagination-ellipsis ...
a.pagination-link(href=`?page=${pagination.totalPages}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= pagination.totalPages
if pagination.hasNext
a.pagination-link(href=`?page=${pagination.page + 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 下一页 ›
//- 统计信息
.list-footer
.list-stats
| 共 #{pagination.total} 条联系信息
if filters.status || filters.q
|,当前显示筛选结果
//- 批量操作(如果需要的话)
.bulk-actions
.bulk-info 提示:点击联系人姓名可以查看完整信息
else
//- 空状态
.empty-state
.empty-icon 📧
.empty-title 暂无联系信息
if filters.status || filters.q
.empty-text 没有找到符合筛选条件的联系信息
a.btn.btn-outline(href="/admin/contacts") 查看全部联系信息
else
.empty-text 还没有用户提交联系表单
.empty-help 用户可以通过前台的联系页面提交联系信息
block $$scripts
script.
// 更新联系信息状态
function updateContactStatus(id, status) {
if (!status) return;
fetch(`/admin/contacts/${id}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('success', data.message || '状态更新成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || '状态更新失败');
}
})
.catch(error => {
console.error('状态更新失败:', error);
showToast('error', '状态更新失败,请稍后重试');
});
}
// 删除联系信息确认
function deleteContact(id, title) {
if (confirm(`确定要删除联系信息《${title}》吗?此操作不可撤销。`)) {
fetch(`/admin/contacts/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('success', data.message || '联系信息删除成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || '删除失败');
}
})
.catch(error => {
console.error('删除失败:', error);
showToast('error', '删除失败,请稍后重试');
});
}
}
// 显示提示消息
function showToast(type, message) {
const toast = document.createElement('div');
toast.className = `admin-toast toast-${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close">×</button>
`;
document.body.appendChild(toast);
// 自动消失
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
// 点击关闭
toast.querySelector('.toast-close').addEventListener('click', () => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
});
}
// 重置状态下拉框
document.querySelectorAll('.status-select').forEach(select => {
select.value = '';
});

229
src/views/admin/contacts/show.pug

@ -0,0 +1,229 @@
extends ../../layouts/admin
block $$content
.page-container
//- 页面头部
.page-header
.page-header-left
.breadcrumb
a(href="/admin/contacts") 联系信息管理
span.breadcrumb-separator /
span.breadcrumb-current= contact.subject
h1.page-title= contact.subject
.contact-meta
span.meta-item
strong 状态:
span.status-badge(class=`status-${contact.status}`)
if contact.status === 'unread'
| 📧 未读
else if contact.status === 'read'
| 👁️ 已读
else if contact.status === 'replied'
| ✅ 已回复
span.meta-item
strong 提交时间:
span= new Date(contact.created_at).toLocaleString('zh-CN')
.page-header-right
.action-buttons
.status-actions
if contact.status !== 'read'
button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'read')`) 👁️ 标记已读
if contact.status !== 'replied'
button.btn.btn-success(onclick=`updateContactStatus(${contact.id}, 'replied')`) ✅ 标记已回复
if contact.status !== 'unread'
button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'unread')`) 📧 标记未读
button.btn.btn-danger(
onclick=`deleteContact(${contact.id}, '${contact.name} - ${contact.subject}')`
) 🗑️ 删除
//- 联系信息详情
.content-section
.contact-details
//- 联系人信息
.detail-section
h3.section-title 联系人信息
.info-grid
.info-item
.info-label 姓名
.info-value= contact.name
.info-item
.info-label 邮箱
.info-value
a(href=`mailto:${contact.email}`)= contact.email
if contact.ip_address
.info-item
.info-label IP地址
.info-value= contact.ip_address
if contact.user_agent
.info-item
.info-label 浏览器信息
.info-value.user-agent= contact.user_agent
//- 联系主题
.detail-section
h3.section-title 联系主题
.subject-content= contact.subject
//- 联系内容
.detail-section
h3.section-title 联系内容
.message-content
.message-text= contact.message
//- 系统信息
.detail-section
h3.section-title 系统信息
.info-grid
.info-item
.info-label ID
.info-value= contact.id
.info-item
.info-label 创建时间
.info-value= new Date(contact.created_at).toLocaleString('zh-CN')
.info-item
.info-label 更新时间
.info-value= new Date(contact.updated_at).toLocaleString('zh-CN')
.info-item
.info-label 当前状态
.info-value
span.status-badge(class=`status-${contact.status}`)
if contact.status === 'unread'
| 📧 未读
else if contact.status === 'read'
| 👁️ 已读
else if contact.status === 'replied'
| ✅ 已回复
//- 快速回复(可选功能)
.detail-section
h3.section-title 快速操作
.quick-actions
.action-group
h4.action-title 邮件操作
.action-buttons
a.btn.btn-primary(href=`mailto:${contact.email}?subject=Re: ${encodeURIComponent(contact.subject)}&body=${encodeURIComponent('您好,感谢您的联系...')}`) 📧 回复邮件
button.btn.btn-outline(onclick="copyEmail()") 📋 复制邮箱
.action-group
h4.action-title 状态操作
.action-buttons
if contact.status === 'unread'
button.btn.btn-success(onclick=`updateContactStatus(${contact.id}, 'read')`) 标记已读
button.btn.btn-primary(onclick=`updateContactStatus(${contact.id}, 'replied')`) 标记已回复
else if contact.status === 'read'
button.btn.btn-primary(onclick=`updateContactStatus(${contact.id}, 'replied')`) 标记已回复
button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'unread')`) 标记未读
else if contact.status === 'replied'
button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'read')`) 标记已读
button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'unread')`) 标记未读
//- 导航
.detail-navigation
a.btn.btn-outline(href="/admin/contacts") ← 返回列表
block $$scripts
script.
// 更新联系信息状态
function updateContactStatus(id, status) {
fetch(`/admin/contacts/${id}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('success', data.message || '状态更新成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showToast('error', data.message || '状态更新失败');
}
})
.catch(error => {
console.error('状态更新失败:', error);
showToast('error', '状态更新失败,请稍后重试');
});
}
// 删除联系信息确认
function deleteContact(id, title) {
if (confirm(`确定要删除联系信息《${title}》吗?此操作不可撤销。`)) {
fetch(`/admin/contacts/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('success', data.message || '联系信息删除成功');
setTimeout(() => {
window.location.href = '/admin/contacts';
}, 1000);
} else {
showToast('error', data.message || '删除失败');
}
})
.catch(error => {
console.error('删除失败:', error);
showToast('error', '删除失败,请稍后重试');
});
}
}
// 复制邮箱地址
function copyEmail() {
const email = '#{contact.email}';
navigator.clipboard.writeText(email).then(() => {
showToast('success', '邮箱地址已复制到剪贴板');
}).catch(err => {
console.error('复制失败:', err);
// 降级处理
const textArea = document.createElement('textarea');
textArea.value = email;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
showToast('success', '邮箱地址已复制到剪贴板');
} catch (fallbackErr) {
showToast('error', '复制失败,请手动复制');
}
document.body.removeChild(textArea);
});
}
// 显示提示消息
function showToast(type, message) {
const toast = document.createElement('div');
toast.className = `admin-toast toast-${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close">×</button>
`;
document.body.appendChild(toast);
// 自动消失
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
// 点击关闭
toast.querySelector('.toast-close').addEventListener('click', () => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
});
}

125
src/views/admin/dashboard.pug

@ -0,0 +1,125 @@
extends ../layouts/admin
block $$content
.dashboard
//- 页面标题
.page-header
h1.page-title 仪表盘
p.page-subtitle 欢迎回到后台管理系统
//- 统计卡片
.stats-grid
.stats-card
.stats-icon.stats-icon-primary 📧
.stats-info
.stats-number= contactStats.total
.stats-label 总联系信息
.stats-detail
.stats-breakdown
span.breakdown-item
span.breakdown-label 未读:
span.breakdown-value= contactStats.unread
span.breakdown-item
span.breakdown-label 已读:
span.breakdown-value= contactStats.read
span.breakdown-item
span.breakdown-label 已回复:
span.breakdown-value= contactStats.replied
.stats-card
.stats-icon.stats-icon-success 📝
.stats-info
.stats-number= articleStats.total
.stats-label 我的文章
.stats-detail
.stats-breakdown
span.breakdown-item
span.breakdown-label 已发布:
span.breakdown-value= articleStats.published
span.breakdown-item
span.breakdown-label 草稿:
span.breakdown-value= articleStats.draft
//- 主要内容区域
.dashboard-content
.dashboard-grid
//- 最近联系信息
.dashboard-section
.section-header
h2.section-title 最近联系信息
a.section-action(href="/admin/contacts") 查看全部 →
if recentContacts && recentContacts.length > 0
.contact-list
each contact in recentContacts
.contact-item(class=`status-${contact.status}`)
.contact-header
.contact-info
strong.contact-name= contact.name
span.contact-email= contact.email
.contact-meta
span.contact-status(class=`status-${contact.status}`)
if contact.status === 'unread'
| 未读
else if contact.status === 'read'
| 已读
else if contact.status === 'replied'
| 已回复
span.contact-date= new Date(contact.created_at).toLocaleDateString('zh-CN')
.contact-subject= contact.subject
.contact-actions
a.btn.btn-sm(href=`/admin/contacts/${contact.id}`) 查看详情
else
.empty-state
.empty-icon 📧
.empty-text 暂无联系信息
//- 最近文章
.dashboard-section
.section-header
h2.section-title 最近文章
a.section-action(href="/admin/articles") 查看全部 →
if recentArticles && recentArticles.length > 0
.article-list
each article in recentArticles
.article-item
.article-header
.article-info
strong.article-title= article.title
span.article-status(class=`status-${article.status}`)
if article.status === 'published'
| 已发布
else if article.status === 'draft'
| 草稿
.article-meta
span.article-date= new Date(article.updated_at).toLocaleDateString('zh-CN')
if article.excerpt
.article-summary= article.excerpt.substring(0, 100) + (article.excerpt.length > 100 ? '...' : '')
.article-actions
a.btn.btn-sm(href=`/admin/articles/${article.id}`) 查看
a.btn.btn-sm.btn-outline(href=`/admin/articles/${article.id}/edit`) 编辑
else
.empty-state
.empty-icon 📝
.empty-text 暂无文章
a.btn.btn-primary(href="/admin/articles/create") 创建第一篇文章
//- 快速操作
.quick-actions
h3.quick-actions-title 快速操作
.quick-actions-grid
a.quick-action-card(href="/admin/articles/create")
.quick-action-icon 📝
.quick-action-title 新建文章
.quick-action-desc 创建一篇新的文章
a.quick-action-card(href="/admin/articles")
.quick-action-icon 📚
.quick-action-title 管理文章
.quick-action-desc 查看和编辑我的文章
a.quick-action-card(href="/admin/contacts")
.quick-action-icon 📧
.quick-action-title 联系信息
.quick-action-desc 查看用户联系信息

128
src/views/layouts/admin.pug

@ -0,0 +1,128 @@
include utils.pug
doctype html
html(lang="zh-CN")
head
block $$head
title #{title ? title + ' - ' : ''}后台管理 - #{$site && $site.site_title || ''}
meta(name="description" content="后台管理系统")
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
+css('lib/reset.css')
+css('css/admin.css')
+js('lib/htmx.min.js')
+js('lib/tailwindcss.3.4.17.js')
body.admin-body
#admin-app
//- 顶部导航栏
header.admin-header
.admin-header-left
.admin-logo
a(href="/admin") 后台管理
.admin-header-center
.admin-breadcrumb
span.breadcrumb-item
a(href="/admin") 首页
if title
span.breadcrumb-separator /
span.breadcrumb-item= title
.admin-header-right
.admin-user-menu
.dropdown
button.dropdown-trigger
span= $user ? $user.name || $user.username : '用户'
i.dropdown-arrow ▼
.dropdown-menu
a.dropdown-item(href="/profile") 个人资料
a.dropdown-item(href="/") 前台首页
.dropdown-divider
a.dropdown-item(href="/logout") 退出登录
//- 主要内容区域
.admin-main
//- 左侧导航
aside.admin-sidebar
nav.admin-nav
.nav-section
.nav-title 主要功能
ul.nav-list
li.nav-item
a.nav-link(href="/admin" class=title === '后台管理' ? 'active' : '')
i.nav-icon 📊
span 仪表盘
li.nav-item
a.nav-link(href="/admin/articles" class=title === '文章管理' ? 'active' : '')
i.nav-icon 📝
span 文章管理
li.nav-item
a.nav-link(href="/admin/contacts" class=title === '联系信息管理' ? 'active' : '')
i.nav-icon 📧
span 联系信息
//- 右侧内容区域
main.admin-content
if toast
.admin-toast(class=`toast-${toast.type}`)
span= toast.message
button.toast-close ×
.admin-content-inner
block $$content
//- JavaScript
script(src="/js/admin.js")
block $$scripts
//- 处理Toast消息
script.
// Toast消息处理
const toast = document.querySelector('.admin-toast');
if (toast) {
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
toast.style.opacity = '0';
setTimeout(() => {
toast.remove();
}, 300);
});
}
}
// 下拉菜单处理
const dropdown = document.querySelector('.dropdown');
if (dropdown) {
const trigger = dropdown.querySelector('.dropdown-trigger');
const menu = dropdown.querySelector('.dropdown-menu');
trigger.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('active');
});
document.addEventListener('click', () => {
dropdown.classList.remove('active');
});
}
// 移动端侧边栏切换
function toggleSidebar() {
document.body.classList.toggle('sidebar-open');
}
// 响应式处理
function handleResize() {
if (window.innerWidth > 768) {
document.body.classList.remove('sidebar-open');
}
}
window.addEventListener('resize', handleResize);
handleResize();

8
src/views/page/articles/article.pug

@ -37,7 +37,7 @@ block pageContent
.prose.prose-lg.max-w-none.mb-8.markdown-content(class="prose-pre:bg-gray-100 prose-pre:p-4 prose-pre:rounded-lg prose-code:text-blue-600 prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-img:rounded-lg prose-img:shadow-md")
!= article.content
if article.keywords || article.description
if article.keywords || article.excerpt
.bg-gray-50.rounded-lg.p-6.mb-8
if article.keywords
.mb-4
@ -45,9 +45,9 @@ block pageContent
.flex.flex-wrap.gap-2
each keyword in article.keywords.split(',')
span.bg-white.px-3.py-1.rounded-full.text-sm= keyword.trim()
if article.description
h3.text-lg.font-semibold.mb-2 描述
p.text-gray-600= article.description
if article.excerpt
h3.text-lg.font-semibold.mb-2 摘要
p.text-gray-600= article.excerpt
if relatedArticles && relatedArticles.length
section.border-t.pt-8.mt-8

2
src/views/page/index/index.pug

@ -31,7 +31,7 @@ mixin card(blog)
class="text-gray-600 text-base mb-4 line-clamp-2"
style="height: 2.8em; overflow: hidden;"
)
| #{blog.description}
| #{blog.excerpt}
a(href="/articles/"+blog.slug class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 →
mixin empty()

Loading…
Cancel
Save