Browse Source
- 新增完整后台管理系统设计文档,涵盖架构、模块、数据模型、 页面布局、核心功能、技术规范、权限及安全策略 - 设计文章管理模块和联系信息管理模块的功能及数据流程 - 确定控制器、服务、模型及视图层设计方案与文件结构 - 实现后台独立样式文件 admin.css,包含导航栏、侧边栏、 文章列表、联系信息列表等样式 - 建立响应式布局及现代化管理界面风格,提升用户体验 - 规划单元测试、集成测试及安全测试策略确保系统稳定性main
26 changed files with 5726 additions and 47 deletions
@ -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攻击防护测试 |
||||
|
- 数据验证测试 |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
@ -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 = ``; |
||||
|
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); |
||||
|
|
||||
|
})(); |
||||
@ -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 |
||||
@ -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'); |
||||
|
}; |
||||
@ -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 } |
||||
@ -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 |
||||
@ -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; |
||||
|
} |
||||
|
}); |
||||
@ -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; |
||||
|
} |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
|
} |
||||
@ -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); |
||||
|
}); |
||||
|
} |
||||
@ -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 = ''; |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
|
} |
||||
@ -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 查看用户联系信息 |
||||
@ -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(); |
||||
Loading…
Reference in new issue