diff --git a/.qoder/quests/admin-backend-implementation.md b/.qoder/quests/admin-backend-implementation.md new file mode 100644 index 0000000..42e3811 --- /dev/null +++ b/.qoder/quests/admin-backend-implementation.md @@ -0,0 +1,355 @@ +# Admin后台管理系统设计文档 + +## 概述 + +为 koa3-demo 项目设计并实现一个完整的后台管理系统,允许注册用户管理自己的文章并查看联系我们的提交信息。系统采用传统的左侧导航栏布局,不继承现有页面样式,完全独立实现。 + +### 核心需求 +- 注册用户可以对自己的文章进行增删改查操作 +- 展示联系表单提交的信息 +- 采用Session认证,不使用API接口 +- 独立的管理界面,左侧导航栏+右侧内容区域 +- 不允许修改其他现有代码 + +## 架构设计 + +### 整体架构图 + +```mermaid +graph TB + A[用户访问 /admin] --> B[AdminController] + B --> C{Session验证} + C -->|未登录| D[跳转登录页] + C -->|已登录| E[后台主界面] + + E --> F[文章管理模块] + E --> G[联系信息模块] + + F --> H[ArticleService] + G --> I[ContactService] + + H --> J[ArticleModel] + I --> K[ContactModel] + + J --> L[(Articles表)] + K --> M[(Contacts表)] +``` + +### 模块架构 + +```mermaid +classDiagram + class AdminController { + +dashboard() + +articlesIndex() + +articleShow() + +articleCreate() + +articleEdit() + +articleUpdate() + +articleDelete() + +contactsIndex() + +contactShow() + +contactDelete() + } + + class ContactModel { + +findAll() + +findById() + +create() + +delete() + +findByDateRange() + } + + class ContactService { + +getAllContacts() + +getContactById() + +deleteContact() + +getContactsByDateRange() + } + + AdminController --> ContactService + AdminController --> ArticleService + ContactService --> ContactModel + ArticleService --> ArticleModel +``` + +## 数据模型设计 + +### 联系信息表 (contacts) + +| 字段名 | 类型 | 约束 | 描述 | +|--------|------|------|------| +| id | INTEGER | PRIMARY KEY | 主键ID | +| name | VARCHAR(100) | NOT NULL | 联系人姓名 | +| email | VARCHAR(255) | NOT NULL | 邮箱地址 | +| subject | VARCHAR(255) | NOT NULL | 主题 | +| message | TEXT | NOT NULL | 留言内容 | +| ip_address | VARCHAR(45) | NULL | IP地址 | +| user_agent | TEXT | NULL | 浏览器信息 | +| status | ENUM('unread','read','replied') | DEFAULT 'unread' | 处理状态 | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 | + +### 数据库迁移设计 + +```sql +-- 创建联系信息表 +CREATE TABLE contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + status VARCHAR(20) DEFAULT 'unread', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_contacts_status ON contacts(status); +CREATE INDEX idx_contacts_created_at ON contacts(created_at); +CREATE INDEX idx_contacts_email ON contacts(email); +``` + +## 后台界面设计 + +### 布局结构 + +``` +┌─────────────────────────────────────────────────────┐ +│ 顶部导航栏 │ +├──────────────┬──────────────────────────────────────┤ +│ │ │ +│ 左侧导航 │ 主内容区域 │ +│ │ │ +│ - 仪表盘 │ ┌────────────────────────────────┐ │ +│ - 文章管理 │ │ │ │ +│ - 所有文章 │ │ 页面内容 │ │ +│ - 新建文章 │ │ │ │ +│ - 联系信息 │ │ │ │ +│ │ └────────────────────────────────┘ │ +│ │ │ +└──────────────┴──────────────────────────────────────┘ +``` + +### 页面流程图 + +```mermaid +flowchart TD + A[访问 /admin] --> B{用户已登录?} + B -->|否| C[跳转到登录页] + B -->|是| D[后台仪表盘] + + D --> E[文章管理] + D --> F[联系信息管理] + + E --> G[文章列表] + E --> H[新建文章] + G --> I[编辑文章] + G --> J[删除文章] + + F --> K[联系信息列表] + K --> L[查看详情] + K --> M[删除信息] + + C --> N[登录成功] --> D +``` + +## 核心功能模块 + +### 1. 文章管理模块 + +#### 功能列表 +- **文章列表**: 显示当前用户的所有文章,支持状态筛选(草稿/已发布) +- **新建文章**: 创建新文章,支持Markdown编辑 +- **编辑文章**: 修改现有文章内容 +- **删除文章**: 删除指定文章 +- **发布/取消发布**: 切换文章发布状态 + +#### 权限控制 +- 用户只能操作自己创建的文章 +- 通过 `author` 字段进行权限过滤 + +#### 数据流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant AC as AdminController + participant AS as ArticleService + participant AM as ArticleModel + participant DB as 数据库 + + U->>AC: 访问文章列表 + AC->>AS: getUserArticles(userId) + AS->>AM: findByAuthor(userId) + AM->>DB: SELECT * FROM articles WHERE author = userId + DB-->>AM: 返回文章列表 + AM-->>AS: 文章数据 + AS-->>AC: 处理后的文章列表 + AC-->>U: 渲染文章管理页面 +``` + +### 2. 联系信息管理模块 + +#### 功能列表 +- **信息列表**: 显示所有联系表单提交的信息 +- **查看详情**: 查看完整的联系信息内容 +- **状态管理**: 标记为已读/未读/已回复 +- **删除信息**: 删除不需要的联系信息 +- **搜索筛选**: 按时间、状态、邮箱等条件筛选 + +#### 数据流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant AC as AdminController + participant CS as ContactService + participant CM as ContactModel + participant DB as 数据库 + + U->>AC: 访问联系信息列表 + AC->>CS: getAllContacts() + CS->>CM: findAll() + CM->>DB: SELECT * FROM contacts ORDER BY created_at DESC + DB-->>CM: 返回联系信息列表 + CM-->>CS: 联系信息数据 + CS-->>AC: 处理后的信息列表 + AC-->>U: 渲染联系信息页面 +``` + +## 技术实现规范 + +### 1. 控制器层设计 + +**AdminController.js** - 后台管理主控制器 +- 继承现有项目架构模式 +- 使用session进行用户认证 +- 所有路由需要登录权限 + +### 2. 服务层设计 + +**ContactService.js** - 联系信息业务逻辑 +- 提供联系信息的CRUD操作 +- 实现状态管理功能 +- 支持分页和搜索 + +### 3. 数据访问层设计 + +**ContactModel.js** - 联系信息数据模型 +- 实现基础CRUD操作 +- 支持条件查询和排序 +- 与现有模型保持一致的设计模式 + +### 4. 视图层设计 + +**布局文件**: `admin.pug` - 后台专用布局 +- 独立的CSS样式,不继承现有页面 +- 响应式左侧导航栏设计 +- 现代化的管理界面风格 + +**页面模板**: +- `admin/dashboard.pug` - 仪表盘首页 +- `admin/articles/index.pug` - 文章列表页 +- `admin/articles/create.pug` - 新建文章页 +- `admin/articles/edit.pug` - 编辑文章页 +- `admin/contacts/index.pug` - 联系信息列表页 +- `admin/contacts/show.pug` - 联系信息详情页 + +### 5. 路由设计 + +``` +/admin GET - 后台首页(仪表盘) +/admin/articles GET - 文章列表 +/admin/articles/create GET - 新建文章页面 +/admin/articles POST - 创建文章 +/admin/articles/:id GET - 查看文章详情 +/admin/articles/:id/edit GET - 编辑文章页面 +/admin/articles/:id PUT - 更新文章 +/admin/articles/:id DELETE - 删除文章 +/admin/contacts GET - 联系信息列表 +/admin/contacts/:id GET - 联系信息详情 +/admin/contacts/:id DELETE - 删除联系信息 +/admin/contacts/:id/status PUT - 更新联系信息状态 +``` + +## 安全考虑 + +### 1. 权限控制 +- 所有后台路由需要用户登录 +- 文章操作权限验证:用户只能操作自己的文章 +- 联系信息管理:所有登录用户都可查看 + +### 2. 数据验证 +- 服务端表单验证 +- XSS防护:模板自动转义 +- CSRF保护:利用现有session机制 + +### 3. 操作日志 +- 记录重要操作(删除文章、删除联系信息) +- 利用现有logger系统 + +## 集成方案 + +### 1. 现有系统集成 +- **联系表单增强**: 修改BasePageController中的contactPost方法,将数据存储到数据库 +- **用户认证复用**: 利用现有session认证机制 +- **数据库集成**: 使用现有Knex.js配置和迁移系统 + +### 2. 不影响现有功能 +- 新增模块独立部署在 `/admin` 路径下 +- 不修改现有控制器、服务和模型 +- 独立的样式文件,避免样式冲突 + +## 文件结构 + +``` +src/ +├── controllers/ +│ └── Page/ +│ └── AdminController.js # 后台管理控制器 +├── services/ +│ └── ContactService.js # 联系信息服务 +├── db/ +│ ├── models/ +│ │ └── ContactModel.js # 联系信息模型 +│ └── migrations/ +│ └── xxxx_create_contacts_table.mjs # 联系表迁移文件 +├── views/ +│ ├── layouts/ +│ │ └── admin.pug # 后台布局模板 +│ └── admin/ +│ ├── dashboard.pug # 仪表盘 +│ ├── articles/ +│ │ ├── index.pug # 文章列表 +│ │ ├── create.pug # 新建文章 +│ │ └── edit.pug # 编辑文章 +│ └── contacts/ +│ ├── index.pug # 联系信息列表 +│ └── show.pug # 联系信息详情 +└── public/ + ├── css/ + │ └── admin.css # 后台专用样式 + └── js/ + └── admin.js # 后台专用脚本 +``` + +## 测试策略 + +### 单元测试 +- ContactModel CRUD操作测试 +- ContactService业务逻辑测试 +- AdminController路由处理测试 + +### 集成测试 +- 用户权限验证测试 +- 文章管理完整流程测试 +- 联系信息管理流程测试 + +### 安全测试 +- 权限绕过测试 +- XSS攻击防护测试 +- 数据验证测试 \ No newline at end of file diff --git a/database/db.sqlite3 b/database/db.sqlite3 new file mode 100644 index 0000000..3f03792 Binary files /dev/null and b/database/db.sqlite3 differ diff --git a/database/db.sqlite3-shm b/database/db.sqlite3-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/database/db.sqlite3-shm differ diff --git a/database/db.sqlite3-wal b/database/db.sqlite3-wal new file mode 100644 index 0000000..e69de29 diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 46c674f..fdf2825 100644 Binary files a/database/development.sqlite3-shm and b/database/development.sqlite3-shm differ diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal index e234aab..190e9ff 100644 Binary files a/database/development.sqlite3-wal and b/database/development.sqlite3-wal differ diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..e944999 --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,1688 @@ +/* ============================ + Admin 后台管理系统样式 + ============================ */ + +/* 基础重置 */ +.admin-body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; + background-color: #f8fafc; + color: #2d3748; + line-height: 1.6; +} + +/* 主要容器 */ +#admin-app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ========== 顶部导航栏 ========== */ +.admin-header { + background: #ffffff; + border-bottom: 1px solid #e2e8f0; + padding: 0 1.5rem; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.admin-header-left { + display: flex; + align-items: center; +} + +.admin-logo a { + font-size: 1.25rem; + font-weight: 600; + color: #2b6cb0; + text-decoration: none; +} + +.admin-header-center { + flex: 1; + display: flex; + justify-content: center; +} + +.admin-breadcrumb { + display: flex; + align-items: center; + color: #718096; + font-size: 0.875rem; +} + +.breadcrumb-item a { + color: #4299e1; + text-decoration: none; +} + +.breadcrumb-separator { + margin: 0 0.5rem; + color: #a0aec0; +} + +.admin-header-right { + display: flex; + align-items: center; +} + +/* 用户下拉菜单 */ +.admin-user-menu { + position: relative; +} + +.dropdown { + position: relative; +} + +.dropdown-trigger { + background: none; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: border-color 0.2s; +} + +.dropdown-trigger:hover { + border-color: #cbd5e0; +} + +.dropdown-arrow { + font-size: 0.75rem; + transition: transform 0.2s; +} + +.dropdown.active .dropdown-arrow { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + min-width: 160px; + z-index: 200; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: all 0.2s; +} + +.dropdown.active .dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.dropdown-item { + display: block; + padding: 0.75rem 1rem; + color: #2d3748; + text-decoration: none; + transition: background-color 0.2s; +} + +.dropdown-item:hover { + background-color: #f7fafc; +} + +.dropdown-divider { + height: 1px; + background: #e2e8f0; + margin: 0.25rem 0; +} + +/* ========== 主要内容区域 ========== */ +.admin-main { + flex: 1; + display: flex; + min-height: calc(100vh - 60px); +} + +/* ========== 左侧导航栏 ========== */ +.admin-sidebar { + width: 250px; + background: #ffffff; + border-right: 1px solid #e2e8f0; + padding: 1.5rem 0; + overflow-y: auto; +} + +.admin-nav { + height: 100%; +} + +.nav-section { + margin-bottom: 2rem; +} + +.nav-title { + padding: 0 1.5rem; + font-size: 0.75rem; + font-weight: 600; + color: #a0aec0; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.nav-list { + list-style: none; + margin: 0; + padding: 0; +} + +.nav-item { + margin: 0; +} + +.nav-link { + display: flex; + align-items: center; + padding: 0.75rem 1.5rem; + color: #4a5568; + text-decoration: none; + transition: all 0.2s; + border-left: 3px solid transparent; +} + +.nav-link:hover { + background-color: #f7fafc; + color: #2b6cb0; +} + +.nav-link.active { + background-color: #ebf8ff; + color: #2b6cb0; + border-left-color: #4299e1; + font-weight: 500; +} + +.nav-icon { + margin-right: 0.75rem; + font-size: 1rem; +} + +/* ========== 右侧内容区域 ========== */ +.admin-content { + flex: 1; + background: #f8fafc; + overflow-y: auto; +} + +.admin-content-inner { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +/* ========== Toast 消息 ========== */ +.admin-toast { + position: fixed; + top: 80px; + right: 20px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem 1.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-width: 300px; + max-width: 500px; + transition: opacity 0.3s; +} + +.admin-toast.toast-success { + border-left: 4px solid #48bb78; + background: #f0fff4; +} + +.admin-toast.toast-error { + border-left: 4px solid #f56565; + background: #fff5f5; +} + +.admin-toast.toast-warning { + border-left: 4px solid #ed8936; + background: #fffaf0; +} + +.admin-toast.toast-info { + border-left: 4px solid #4299e1; + background: #ebf8ff; +} + +.toast-close { + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + color: #a0aec0; + padding: 0; + line-height: 1; +} + +.toast-close:hover { + color: #718096; +} + +/* ========== 页面组件 ========== */ + +/* 页面容器 */ +.page-container { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +/* 页面头部 */ +.page-header { + padding: 2rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.page-header-left { + flex: 1; +} + +.page-title { + font-size: 1.875rem; + font-weight: 600; + color: #1a202c; + margin: 0 0 0.5rem 0; +} + +.page-subtitle { + color: #718096; + margin: 0; +} + +.page-header-right { + flex-shrink: 0; + margin-left: 2rem; +} + +/* 面包屑 */ +.breadcrumb { + margin-bottom: 1rem; + font-size: 0.875rem; + color: #718096; +} + +.breadcrumb a { + color: #4299e1; + text-decoration: none; +} + +.breadcrumb-separator { + margin: 0 0.5rem; +} + +.breadcrumb-current { + color: #2d3748; + font-weight: 500; +} + +/* ========== 表单组件 ========== */ + +/* 按钮 */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; + gap: 0.5rem; +} + +.btn-primary { + background: #4299e1; + color: white; + border-color: #4299e1; +} + +.btn-primary:hover { + background: #3182ce; + border-color: #3182ce; +} + +.btn-secondary { + background: #718096; + color: white; + border-color: #718096; +} + +.btn-secondary:hover { + background: #4a5568; + border-color: #4a5568; +} + +.btn-success { + background: #48bb78; + color: white; + border-color: #48bb78; +} + +.btn-success:hover { + background: #38a169; + border-color: #38a169; +} + +.btn-danger { + background: #f56565; + color: white; + border-color: #f56565; +} + +.btn-danger:hover { + background: #e53e3e; + border-color: #e53e3e; +} + +.btn-outline { + background: transparent; + color: #4a5568; + border-color: #e2e8f0; +} + +.btn-outline:hover { + background: #f7fafc; + border-color: #cbd5e0; +} + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.75rem; +} + +/* 表单元素 */ +.form-group { + margin-bottom: 1.5rem; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #2d3748; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.875rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: #4299e1; + box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-help { + margin-top: 0.25rem; + font-size: 0.75rem; + color: #718096; +} + +/* ========== 表格组件 ========== */ +.table-container { + overflow-x: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid #e2e8f0; +} + +.table th { + background: #f7fafc; + font-weight: 600; + color: #2d3748; + font-size: 0.875rem; +} + +.table tbody tr:hover { + background: #f7fafc; +} + +/* ========== 状态徽章 ========== */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + gap: 0.25rem; +} + +.status-badge.status-published { + background: #c6f6d5; + color: #22543d; +} + +.status-badge.status-draft { + background: #feebc8; + color: #744210; +} + +.status-badge.status-unread { + background: #bee3f8; + color: #2a4365; +} + +.status-badge.status-read { + background: #d6f5d6; + color: #22543d; +} + +.status-badge.status-replied { + background: #c6f6d5; + color: #22543d; +} + +/* ========== 分页组件 ========== */ +.pagination-container { + margin-top: 2rem; + display: flex; + justify-content: center; +} + +.pagination { + display: flex; + gap: 0.25rem; +} + +.pagination-link { + padding: 0.5rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + color: #4a5568; + text-decoration: none; + transition: all 0.2s; +} + +.pagination-link:hover { + background: #f7fafc; + border-color: #cbd5e0; +} + +.pagination-link.active { + background: #4299e1; + color: white; + border-color: #4299e1; +} + +.pagination-ellipsis { + padding: 0.5rem 0.75rem; + color: #a0aec0; +} + +/* ========== 特定页面样式 ========== */ + +/* 仪表盘 */ +.dashboard { + padding: 2rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stats-card { + background: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.stats-icon { + font-size: 2rem; + padding: 0.75rem; + border-radius: 8px; +} + +.stats-icon-primary { + background: #ebf8ff; +} + +.stats-icon-success { + background: #f0fff4; +} + +.stats-number { + font-size: 2rem; + font-weight: 600; + color: #1a202c; + margin-bottom: 0.25rem; +} + +.stats-label { + color: #718096; + font-size: 0.875rem; +} + +.stats-breakdown { + margin-top: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.75rem; +} + +.breakdown-item { + color: #718096; +} + +.breakdown-value { + font-weight: 500; + color: #2d3748; +} + +/* 文章表格 */ +.article-table-container { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.article-table { + width: 100%; + border-collapse: collapse; +} + +.article-table th { + background: #f7fafc; + padding: 1rem; + font-weight: 600; + color: #2d3748; + border-bottom: 1px solid #e2e8f0; +} + +.article-table td { + padding: 1rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: top; +} + +.article-row:hover { + background: #f8fafc; +} + +.article-title-cell h3 { + margin: 0 0 0.5rem 0; + font-size: 1rem; + font-weight: 500; +} + +.article-title-cell a { + color: #2b6cb0; + text-decoration: none; +} + +.article-title-cell a:hover { + color: #2c5aa0; + text-decoration: underline; +} + +.article-summary { + color: #718096; + font-size: 0.875rem; + margin: 0.5rem 0; +} + +.article-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.tag { + background: #edf2f7; + color: #4a5568; + padding: 0.125rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; +} + +.category-badge { + background: #e6fffa; + color: #234e52; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.date-info { + font-size: 0.875rem; +} + +.primary-date { + color: #2d3748; + font-weight: 500; +} + +.secondary-date { + color: #718096; + font-size: 0.75rem; +} + +.action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* 联系信息表格 */ +.contact-table-container { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.contact-table { + width: 100%; + border-collapse: collapse; +} + +.contact-table th { + background: #f7fafc; + padding: 1rem; + font-weight: 600; + color: #2d3748; + border-bottom: 1px solid #e2e8f0; +} + +.contact-table td { + padding: 1rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: top; +} + +.contact-row:hover { + background: #f8fafc; +} + +.contact-row.status-unread { + background: #f0f9ff; +} + +.contact-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.contact-name { + font-weight: 500; + color: #2d3748; +} + +.contact-email { + color: #4299e1; + font-size: 0.875rem; +} + +.contact-ip { + color: #718096; + font-size: 0.75rem; +} + +.subject-content h4 { + margin: 0 0 0.5rem 0; + font-size: 1rem; + font-weight: 500; + color: #2d3748; +} + +.subject-preview { + color: #718096; + font-size: 0.875rem; +} + +.status-dropdown { + margin: 0.25rem 0; +} + +.status-select { + padding: 0.25rem 0.5rem; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 0.75rem; + background: white; +} + +/* 筛选组件 */ +.page-filters { + background: white; + padding: 1.5rem 2rem; + border-bottom: 1px solid #e2e8f0; +} + +.filter-form { + display: flex; + gap: 1.5rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.filter-label { + font-size: 0.875rem; + font-weight: 500; + color: #2d3748; +} + +.filter-select { + padding: 0.5rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.875rem; + min-width: 150px; +} + +.search-box { + display: flex; + border: 1px solid #e2e8f0; + border-radius: 6px; + overflow: hidden; +} + +.search-input { + flex: 1; + padding: 0.5rem 0.75rem; + border: none; + outline: none; + min-width: 200px; +} + +.search-btn { + padding: 0.5rem 0.75rem; + background: #4299e1; + color: white; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.search-btn:hover { + background: #3182ce; +} + +.filter-clear { + color: #e53e3e; + text-decoration: none; + font-size: 0.875rem; + padding: 0.5rem 0; +} + +.filter-clear:hover { + text-decoration: underline; +} + +/* 内容区域 */ +.content-section { + padding: 2rem; +} + +/* 空状态 */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: #718096; +} + +.empty-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.empty-title { + font-size: 1.25rem; + font-weight: 500; + color: #2d3748; + margin-bottom: 0.5rem; +} + +.empty-text { + margin-bottom: 1.5rem; +} + +/* 列表底部 */ +.list-footer { + padding: 1rem 2rem; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + color: #718096; + font-size: 0.875rem; +} + +/* ========== 文章管理特定样式 ========== */ + +/* 文章表单样式 */ +.article-form { + background: white; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 300px; + gap: 2rem; +} + +.form-main { + flex: 1; +} + +.form-sidebar { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1.5rem; + height: fit-content; + position: sticky; + top: 1rem; +} + +.sidebar-section { + margin-bottom: 2rem; +} + +.sidebar-section:last-child { + margin-bottom: 0; +} + +.sidebar-title { + font-size: 1rem; + font-weight: 600; + color: #2d3748; + margin: 0 0 1rem 0; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 0.5rem; +} + +.content-editor { + min-height: 400px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; + line-height: 1.6; +} + +.form-actions { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid #e2e8f0; +} + +.action-buttons { + display: flex; + gap: 1rem; + align-items: center; +} + +/* 文章信息显示 */ +.article-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 1rem; + font-size: 0.875rem; +} + +.meta-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.article-view { + max-width: none; +} + +.article-summary-section, +.article-content-section, +.article-tags-section, +.article-link-section, +.article-technical-section { + margin-bottom: 2rem; + padding: 1.5rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: white; +} + +.section-title { + font-size: 1.125rem; + font-weight: 600; + color: #2d3748; + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e2e8f0; +} + +.article-content { + background: #f8fafc; + padding: 1.5rem; + border-radius: 6px; + border: 1px solid #e2e8f0; + line-height: 1.7; + color: #2d3748; +} + +.empty-content { + color: #a0aec0; + font-style: italic; + text-align: center; + padding: 2rem; +} + +.link-info { + font-size: 0.875rem; +} + +.article-link { + color: #4299e1; + text-decoration: none; + margin-left: 0.5rem; +} + +.article-link:hover { + text-decoration: underline; +} + +.technical-info { + background: #f8fafc; + padding: 1rem; + border-radius: 6px; + border: 1px solid #e2e8f0; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.info-label { + font-size: 0.75rem; + font-weight: 600; + color: #718096; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-value { + font-size: 0.875rem; + color: #2d3748; + word-break: break-all; +} + +/* ========== 联系信息管理特定样式 ========== */ + +/* 统计摘要 */ +.stats-summary { + display: flex; + gap: 1.5rem; + align-items: center; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 1rem; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + min-width: 80px; +} + +.stat-item.unread { + background: #f0f9ff; + border-color: #bfdbfe; +} + +.stat-number { + font-size: 1.5rem; + font-weight: 600; + color: #2d3748; + margin-bottom: 0.25rem; +} + +.stat-label { + font-size: 0.75rem; + color: #718096; + text-align: center; +} + +/* 联系信息详情 */ +.contact-details { + max-width: none; +} + +.detail-section { + margin-bottom: 2rem; + padding: 1.5rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: white; +} + +.subject-content { + font-size: 1.125rem; + font-weight: 500; + color: #2d3748; + line-height: 1.5; +} + +.message-content { + background: #f8fafc; + padding: 1.5rem; + border-radius: 6px; + border: 1px solid #e2e8f0; +} + +.message-text { + line-height: 1.7; + color: #2d3748; + white-space: pre-wrap; + word-wrap: break-word; +} + +.user-agent { + font-size: 0.75rem; + color: #718096; + word-break: break-all; +} + +.quick-actions { + background: #f8fafc; + padding: 1.5rem; + border-radius: 6px; + border: 1px solid #e2e8f0; +} + +.action-group { + margin-bottom: 1.5rem; +} + +.action-group:last-child { + margin-bottom: 0; +} + +.action-title { + font-size: 0.875rem; + font-weight: 600; + color: #4a5568; + margin: 0 0 0.75rem 0; +} + +.status-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.detail-navigation { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid #e2e8f0; +} + +/* ========== 仪表盘特定样式 ========== */ + +.dashboard-content { + margin-top: 2rem; +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 2rem; + margin-bottom: 2rem; +} + +.dashboard-section { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.section-header { + padding: 1.5rem 1.5rem 1rem 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.section-title { + font-size: 1.125rem; + font-weight: 600; + color: #2d3748; + margin: 0; +} + +.section-action { + color: #4299e1; + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; +} + +.section-action:hover { + color: #3182ce; +} + +.contact-list, +.article-list { + padding: 0; +} + +.contact-item, +.article-item { + padding: 1rem 1.5rem; + border-bottom: 1px solid #f1f5f9; +} + +.contact-item:last-child, +.article-item:last-child { + border-bottom: none; +} + +.contact-item.status-unread { + background: #f0f9ff; +} + +.contact-header, +.article-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; +} + +.contact-info, +.article-info { + flex: 1; +} + +.contact-name, +.article-title { + font-weight: 500; + color: #2d3748; + margin-bottom: 0.25rem; +} + +.contact-email { + color: #4299e1; + font-size: 0.875rem; +} + +.contact-meta, +.article-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: #718096; +} + +.contact-status, +.article-status { + padding: 0.125rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.contact-status.status-unread, +.article-status.status-draft { + background: #feebc8; + color: #744210; +} + +.contact-status.status-read, +.contact-status.status-replied, +.article-status.status-published { + background: #c6f6d5; + color: #22543d; +} + +.contact-subject, +.article-summary { + color: #4a5568; + font-size: 0.875rem; + line-height: 1.5; + margin-bottom: 0.75rem; +} + +.contact-actions, +.article-actions { + display: flex; + gap: 0.5rem; +} + +.quick-actions { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.quick-actions-title { + font-size: 1.25rem; + font-weight: 600; + color: #2d3748; + margin: 0 0 1.5rem 0; +} + +.quick-actions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.quick-action-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 1.5rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + text-decoration: none; + color: #2d3748; + transition: all 0.2s; +} + +.quick-action-card:hover { + border-color: #4299e1; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.quick-action-icon { + font-size: 2rem; + margin-bottom: 0.75rem; +} + +.quick-action-title { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.quick-action-desc { + font-size: 0.875rem; + color: #718096; +} + +/* ========== 通用增强样式 ========== */ + +.text-muted { + color: #a0aec0; +} + +.bulk-actions { + padding: 1rem 2rem; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + font-size: 0.875rem; + color: #718096; +} + +.bulk-info { + text-align: center; +} + +/* 文章和联系信息表格列宽 */ +.col-title { + width: 40%; +} + +.col-contact { + width: 25%; +} + +.col-subject { + width: 35%; +} + +.col-status { + width: 12%; +} + +.col-category { + width: 15%; +} + +.col-date { + width: 15%; +} + +.col-actions { + width: 18%; +} + +/* 编辑器增强样式 */ +.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; + line-height: 1; +} + +.editor-btn:hover { + background: #e2e8f0; + border-color: #cbd5e0; +} + +.editor-toolbar + .form-textarea { + border-radius: 0 0 6px 6px; + border-top: none; +} + +/* 表单验证错误样式 */ +.field-error { + color: #f56565; + font-size: 0.75rem; + margin-top: 0.25rem; + display: block; +} + +.form-input.error, +.form-textarea.error, +.form-select.error { + border-color: #f56565; + box-shadow: 0 0 0 3px rgba(245, 101, 101, 0.1); +} + +/* 加载状态 */ +.loading { + opacity: 0.6; + pointer-events: none; + position: relative; +} + +.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid #e2e8f0; + border-top: 2px solid #4299e1; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 选中状态 */ +.selected { + background-color: #ebf8ff !important; +} + +/* 高亮显示 */ +.highlight { + background: #fef5e7; + border: 1px solid #f6e05e; + border-radius: 4px; + padding: 0.25rem 0.5rem; + animation: highlight-fade 2s ease-out; +} + +@keyframes highlight-fade { + 0% { background: #fef5e7; } + 100% { background: transparent; } +} + +/* 工具提示 */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: #1a202c; + color: white; + padding: 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.2s; + z-index: 1000; + margin-bottom: 5px; +} + +.tooltip::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: #1a202c; + opacity: 0; + visibility: hidden; + transition: all 0.2s; +} + +.tooltip:hover::after, +.tooltip:hover::before { + opacity: 1; + visibility: visible; +} + +/* 响应式设计 */ +@media (max-width: 1024px) { + .form-grid { + grid-template-columns: 1fr; + } + + .form-sidebar { + position: static; + margin-top: 2rem; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .quick-actions-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } +} + +@media (max-width: 768px) { + .admin-header { + padding: 0 1rem; + } + + .admin-sidebar { + position: fixed; + left: -250px; + top: 60px; + height: calc(100vh - 60px); + z-index: 50; + transition: left 0.3s; + } + + .sidebar-open .admin-sidebar { + left: 0; + } + + .admin-content-inner { + padding: 1rem; + } + + .page-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .page-header-right { + margin-left: 0; + } + + .filter-form { + flex-direction: column; + align-items: stretch; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .stats-summary { + flex-wrap: wrap; + justify-content: center; + } + + .table-container { + font-size: 0.875rem; + } + + .article-table th, + .article-table td, + .contact-table th, + .contact-table td { + padding: 0.5rem; + } + + .action-buttons { + flex-direction: column; + align-items: stretch; + } + + .status-actions { + flex-direction: column; + } + + .quick-actions-grid { + grid-template-columns: 1fr; + } + + /* 隐藏部分表格列在小屏幕上 */ + .col-category, + .col-date .secondary-date { + display: none; + } + + .article-tags, + .contact-ip { + display: none; + } +} \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..9f16ca3 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,973 @@ +/** + * Admin 后台管理系统 JavaScript + * 提供通用的交互功能和工具函数 + */ + +(function() { + 'use strict'; + + // 通用工具函数 + const AdminUtils = { + /** + * 显示Toast消息 + * @param {string} type - 消息类型 (success, error, warning, info) + * @param {string} message - 消息内容 + * @param {number} duration - 显示时长(毫秒) + */ + showToast: function(type, message, duration = 3000) { + // 移除现有的toast + const existingToast = document.querySelector('.admin-toast'); + if (existingToast) { + existingToast.remove(); + } + + const toast = document.createElement('div'); + toast.className = `admin-toast toast-${type}`; + toast.innerHTML = ` + ${message} + + `; + + 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} 复制是否成功 + */ + 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 = ` +
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+ `; + + // 添加样式 + const style = document.createElement('style'); + style.textContent = ` + .editor-toolbar { + display: flex; + gap: 0.5rem; + padding: 0.75rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-bottom: none; + border-radius: 6px 6px 0 0; + flex-wrap: wrap; + } + .editor-toolbar-group { + display: flex; + gap: 0.25rem; + padding: 0 0.5rem; + border-right: 1px solid #e2e8f0; + } + .editor-toolbar-group:last-child { + border-right: none; + } + .editor-btn { + background: none; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.25rem 0.5rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; + } + .editor-btn:hover { + background: #e2e8f0; + border-color: #cbd5e0; + } + .editor-toolbar + textarea { + border-radius: 0 0 6px 6px; + } + `; + + if (!document.querySelector('#editor-toolbar-style')) { + style.id = 'editor-toolbar-style'; + document.head.appendChild(style); + } + + // 插入工具栏 + textarea.parentNode.insertBefore(toolbar, textarea); + + // 绑定事件 + toolbar.addEventListener('click', (e) => { + if (e.target.classList.contains('editor-btn')) { + e.preventDefault(); + const action = e.target.dataset.action; + this.executeEditorAction(textarea, action); + } + }); + + // 键盘快捷键 + textarea.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault(); + this.executeEditorAction(textarea, 'bold'); + break; + case 'i': + e.preventDefault(); + this.executeEditorAction(textarea, 'italic'); + break; + } + } + }); + }, + + /** + * 执行编辑器动作 + */ + executeEditorAction: function(textarea, action) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = textarea.value.substring(start, end); + const beforeText = textarea.value.substring(0, start); + const afterText = textarea.value.substring(end); + + let insertText = ''; + let cursorOffset = 0; + + switch (action) { + case 'bold': + insertText = `**${selectedText || '粗体文字'}**`; + cursorOffset = selectedText ? 0 : -2; + break; + case 'italic': + insertText = `*${selectedText || '斜体文字'}*`; + cursorOffset = selectedText ? 0 : -1; + break; + case 'code': + insertText = `\`${selectedText || '代码'}\``; + cursorOffset = selectedText ? 0 : -1; + break; + case 'h1': + insertText = `# ${selectedText || '标题 1'}`; + break; + case 'h2': + insertText = `## ${selectedText || '标题 2'}`; + break; + case 'h3': + insertText = `### ${selectedText || '标题 3'}`; + break; + case 'ul': + insertText = `- ${selectedText || '列表项'}`; + break; + case 'ol': + insertText = `1. ${selectedText || '列表项'}`; + break; + case 'quote': + insertText = `> ${selectedText || '引用文字'}`; + break; + case 'link': + const linkText = selectedText || '链接文字'; + insertText = `[${linkText}](URL)`; + cursorOffset = -4; + break; + case 'image': + const altText = selectedText || '图片描述'; + insertText = `![${altText}](URL)`; + cursorOffset = -4; + break; + } + + textarea.value = beforeText + insertText + afterText; + const newCursorPos = start + insertText.length + cursorOffset; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.focus(); + }, + + /** + * 初始化字符计数器 + */ + initCharCounters: function() { + const fields = [ + { id: 'title', max: 200 }, + { id: 'excerpt', max: 500 }, + { id: 'tags', max: 200 }, + { id: 'slug', max: 100 } + ]; + + fields.forEach(field => { + const element = document.getElementById(field.id); + if (element) { + this.setupCharCounter(element, field.max); + } + }); + }, + + /** + * 设置字符计数器 + */ + setupCharCounter: function(element, maxLength) { + const helpElement = element.nextElementSibling; + if (!helpElement || !helpElement.classList.contains('form-help')) { + return; + } + + const originalText = helpElement.textContent; + + const updateCounter = () => { + const currentLength = element.value.length; + const remaining = maxLength - currentLength; + + if (remaining < 50) { + helpElement.textContent = `${originalText} (还可输入${remaining}字符)`; + helpElement.style.color = remaining < 10 ? '#f56565' : '#ed8936'; + } else { + helpElement.textContent = originalText; + helpElement.style.color = ''; + } + }; + + element.addEventListener('input', updateCounter); + updateCounter(); + } + }; + + // 全局删除函数 + window.deleteArticle = function(id, title) { + if (AdminUtils.confirm(`确定要删除文章《${title}》吗?此操作不可撤销。`, '删除确认')) { + AdminUtils.ajax(`/admin/articles/${id}`, { + method: 'DELETE' + }) + .then(data => { + if (data.success) { + AdminUtils.showToast('success', data.message || '文章删除成功'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + AdminUtils.showToast('error', data.message || '删除失败'); + } + }) + .catch(error => { + console.error('删除失败:', error); + AdminUtils.showToast('error', '删除失败,请稍后重试'); + }); + } + }; + + // 全局联系信息删除函数 + window.deleteContact = function(id, title) { + if (AdminUtils.confirm(`确定要删除联系信息《${title}》吗?此操作不可撤销。`, '删除确认')) { + AdminUtils.ajax(`/admin/contacts/${id}`, { + method: 'DELETE' + }) + .then(data => { + if (data.success) { + AdminUtils.showToast('success', data.message || '联系信息删除成功'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + AdminUtils.showToast('error', data.message || '删除失败'); + } + }) + .catch(error => { + console.error('删除失败:', error); + AdminUtils.showToast('error', '删除失败,请稍后重试'); + }); + } + }; + + // 全局状态更新函数 + window.updateContactStatus = function(id, status) { + if (!status) return; + + AdminUtils.ajax(`/admin/contacts/${id}/status`, { + method: 'PUT', + body: { status } + }) + .then(data => { + if (data.success) { + AdminUtils.showToast('success', data.message || '状态更新成功'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + AdminUtils.showToast('error', data.message || '状态更新失败'); + } + }) + .catch(error => { + console.error('状态更新失败:', error); + AdminUtils.showToast('error', '状态更新失败,请稍后重试'); + }); + }; + + // 全局复制邮箱函数 + window.copyEmail = function(email) { + AdminUtils.copyToClipboard(email || document.querySelector('[data-email]')?.dataset.email || ''); + }; + + // 全局预览文章函数 + window.previewArticle = function() { + const titleElement = document.getElementById('title'); + const contentElement = document.getElementById('content'); + + if (!titleElement || !contentElement) { + AdminUtils.showToast('error', '无法找到文章内容'); + return; + } + + const title = titleElement.value.trim(); + const content = contentElement.value.trim(); + + if (!content) { + AdminUtils.showToast('warning', '请先输入文章内容'); + contentElement.focus(); + return; + } + + // 简单的Markdown预览 + const previewWindow = window.open('', '_blank', 'width=800,height=600,scrollbars=yes'); + if (!previewWindow) { + AdminUtils.showToast('error', '无法打开预览窗口,请检查浏览器弹窗设置'); + return; + } + + // 基础的Markdown转换 + let htmlContent = content + .replace(/\n/g, '
') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/^# (.*$)/gim, '

$1

') + .replace(/^## (.*$)/gim, '

$1

') + .replace(/^### (.*$)/gim, '

$1

'); + + previewWindow.document.write(` + + + + + + 文章预览 - ${title || '未设置标题'} + + + +
+

📄 文章预览

+ +
+

${title || '未设置标题'}

+
${htmlContent}
+ + + + `); + 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); + +})(); \ No newline at end of file diff --git a/src/controllers/Page/AdminController.js b/src/controllers/Page/AdminController.js new file mode 100644 index 0000000..5fd0256 --- /dev/null +++ b/src/controllers/Page/AdminController.js @@ -0,0 +1,391 @@ +import Router from "../../utils/router.js" +import ArticleService from "../../services/ArticleService.js" +import ContactService from "../../services/ContactService.js" +import { logger } from "../../logger.js" +import CommonError from "../../utils/error/CommonError.js" + +/** + * 后台管理控制器 + * 负责处理后台管理相关的页面和操作 + */ +class AdminController { + constructor() { + this.articleService = new ArticleService() + this.contactService = new ContactService() + } + + /** + * 后台首页(仪表盘) + */ + async dashboard(ctx) { + try { + // 获取统计数据 + const [contactStats, userArticles] = await Promise.all([ + this.contactService.getContactStats(), + this.articleService.getUserArticles(ctx.session.user.id) + ]); + + // 计算文章统计 + const articleStats = { + total: userArticles.length, + published: userArticles.filter(a => a.status === 'published').length, + draft: userArticles.filter(a => a.status === 'draft').length + }; + + // 获取最近的联系信息 + const recentContacts = await this.contactService.getAllContacts({ + page: 1, + limit: 5, + orderBy: 'created_at', + order: 'desc' + }); + + // 获取最近的文章 + const recentArticles = userArticles + .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) + .slice(0, 5); + + return await ctx.render("admin/dashboard", { + contactStats, + articleStats, + recentContacts: recentContacts.contacts, + recentArticles, + title: "后台管理" + }, { layout: "admin" }); + } catch (error) { + logger.error(`仪表盘加载失败: ${error.message}`); + throw new CommonError("仪表盘加载失败"); + } + } + + /** + * 文章管理 - 列表页 + */ + async articlesIndex(ctx) { + try { + const { page = 1, status = null, q = null } = ctx.query; + const userId = ctx.session.user.id; + + // 使用分页查询,提高性能 + const result = await this.articleService.getUserArticlesWithPagination(userId, { + page: parseInt(page), + limit: 10, + status, + keyword: q + }); + + return await ctx.render("admin/articles/index", { + articles: result.articles, + pagination: result.pagination, + filters: { status, q }, + title: "文章管理" + }, { layout: "admin" }); + } catch (error) { + logger.error(`文章列表加载失败: ${error.message}`); + throw new CommonError("文章列表加载失败"); + } + } + + /** + * 文章管理 - 查看详情 + */ + async articleShow(ctx) { + try { + const { id } = ctx.params; + const userId = ctx.session.user.id; + + const article = await this.articleService.getArticleById(id); + + // 检查权限:只能查看自己的文章 + if (+article.author !== +userId) { + throw new CommonError("无权访问此文章"); + } + + return await ctx.render("admin/articles/show", { + article, + title: `查看文章 - ${article.title}` + }, { layout: "admin" }); + } catch (error) { + logger.error(`文章详情加载失败: ${error.message}`); + if (error instanceof CommonError) { + ctx.throw(403, error.message); + } + throw new CommonError("文章详情加载失败"); + } + } + + /** + * 文章管理 - 新建页面 + */ + async articleCreate(ctx) { + return await ctx.render("admin/articles/create", { + title: "新建文章" + }, { layout: "admin" }); + } + + /** + * 文章管理 - 创建文章 + */ + async articleStore(ctx) { + try { + const userId = ctx.session.user.id; + const data = { + ...ctx.request.body, + author: userId + }; + + const article = await this.articleService.createArticle(data); + + ctx.session.toast = { + type: "success", + message: "文章创建成功" + }; + + ctx.redirect(`/admin/articles/${article.id}`); + } catch (error) { + logger.error(`文章创建失败: ${error.message}`); + ctx.session.toast = { + type: "error", + message: error.message || "文章创建失败" + }; + ctx.redirect("/admin/articles/create"); + } + } + + /** + * 文章管理 - 编辑页面 + */ + async articleEdit(ctx) { + try { + const { id } = ctx.params; + const userId = ctx.session.user.id; + + const article = await this.articleService.getArticleById(id); + + // 检查权限:只能编辑自己的文章 + + if (+article.author !== +userId) { + throw new CommonError("无权编辑此文章"); + } + + return await ctx.render("admin/articles/edit", { + article, + title: `编辑文章 - ${article.title}` + }, { layout: "admin" }); + } catch (error) { + logger.error(`文章编辑页面加载失败: ${error.message}`); + if (error instanceof CommonError) { + ctx.throw(403, error.message); + } + throw new CommonError("文章编辑页面加载失败"); + } + } + + /** + * 文章管理 - 更新文章 + */ + async articleUpdate(ctx) { + try { + const { id } = ctx.params; + const userId = ctx.session.user.id; + + // 检查权限 + const existingArticle = await this.articleService.getArticleById(id); + if (+existingArticle.author !== +userId) { + throw new CommonError("无权编辑此文章"); + } + + const article = await this.articleService.updateArticle(id, ctx.request.body); + + ctx.session.toast = { + type: "success", + message: "文章更新成功" + }; + + ctx.redirect(`/admin/articles/${article.id}`); + } catch (error) { + logger.error(`文章更新失败: ${error.message}`); + ctx.session.toast = { + type: "error", + message: error.message || "文章更新失败" + }; + ctx.redirect(`/admin/articles/${ctx.params.id}/edit`); + } + } + + /** + * 文章管理 - 删除文章 + */ + async articleDelete(ctx) { + try { + const { id } = ctx.params; + const userId = ctx.session.user.id; + + // 检查权限 + const article = await this.articleService.getArticleById(id); + if (+article.author !== +userId) { + throw new CommonError("无权删除此文章"); + } + + await this.articleService.deleteArticle(id); + + ctx.session.toast = { + type: "success", + message: "文章删除成功" + }; + + ctx.body = { success: true, message: "文章删除成功" }; + } catch (error) { + logger.error(`文章删除失败: ${error.message}`); + ctx.status = 500; + ctx.body = { + success: false, + message: error.message || "文章删除失败" + }; + } + } + + /** + * 联系信息管理 - 列表页 + */ + async contactsIndex(ctx) { + try { + const { + page = 1, + status = null, + q = null, + limit = 15 + } = ctx.query; + + let result; + + if (q && q.trim()) { + // 搜索模式 + result = await this.contactService.searchContacts(q, { + page: parseInt(page), + limit: parseInt(limit), + status + }); + } else { + // 普通列表模式 + result = await this.contactService.getAllContacts({ + page: parseInt(page), + limit: parseInt(limit), + status, + orderBy: 'created_at', + order: 'desc' + }); + } + + return await ctx.render("admin/contacts/index", { + contacts: result.contacts, + pagination: result.pagination, + filters: { status, q }, + title: "联系信息管理" + }, { layout: "admin" }); + } catch (error) { + logger.error(`联系信息列表加载失败: ${error.message}`); + throw new CommonError("联系信息列表加载失败"); + } + } + + /** + * 联系信息管理 - 查看详情 + */ + async contactShow(ctx) { + try { + const { id } = ctx.params; + const contact = await this.contactService.getContactById(id); + + // 如果是未读状态,自动标记为已读 + if (contact.status === 'unread') { + await this.contactService.markAsRead(id); + contact.status = 'read'; + } + + return await ctx.render("admin/contacts/show", { + contact, + title: `联系信息详情 - ${contact.subject}` + }, { layout: "admin" }); + } catch (error) { + logger.error(`联系信息详情加载失败: ${error.message}`); + throw new CommonError("联系信息详情加载失败"); + } + } + + /** + * 联系信息管理 - 删除 + */ + async contactDelete(ctx) { + try { + const { id } = ctx.params; + await this.contactService.deleteContact(id); + + ctx.body = { success: true, message: "联系信息删除成功" }; + } catch (error) { + logger.error(`联系信息删除失败: ${error.message}`); + ctx.status = 500; + ctx.body = { + success: false, + message: error.message || "联系信息删除失败" + }; + } + } + + /** + * 联系信息管理 - 更新状态 + */ + async contactUpdateStatus(ctx) { + try { + const { id } = ctx.params; + const { status } = ctx.request.body; + + await this.contactService.updateContactStatus(id, status); + + ctx.body = { success: true, message: "状态更新成功" }; + } catch (error) { + logger.error(`联系信息状态更新失败: ${error.message}`); + ctx.status = 500; + ctx.body = { + success: false, + message: error.message || "状态更新失败" + }; + } + } + + /** + * 创建后台管理路由 + */ + static createRoutes() { + const controller = new AdminController(); + const router = new Router({ + auth: true, + prefix: "/admin", + authFailRedirect: "/login" + }); + + // 后台首页 + router.get("", controller.dashboard.bind(controller)); + router.get("/", controller.dashboard.bind(controller)); + + // 文章管理路由 + router.get("/articles", controller.articlesIndex.bind(controller)); + router.get("/articles/create", controller.articleCreate.bind(controller)); + router.post("/articles", controller.articleStore.bind(controller)); + router.get("/articles/:id", controller.articleShow.bind(controller)); + router.get("/articles/:id/edit", controller.articleEdit.bind(controller)); + router.put("/articles/:id", controller.articleUpdate.bind(controller)); + router.post("/articles/:id", controller.articleUpdate.bind(controller)); // 兼容表单提交 + router.delete("/articles/:id", controller.articleDelete.bind(controller)); + + // 联系信息管理路由 + router.get("/contacts", controller.contactsIndex.bind(controller)); + router.get("/contacts/:id", controller.contactShow.bind(controller)); + router.delete("/contacts/:id", controller.contactDelete.bind(controller)); + router.put("/contacts/:id/status", controller.contactUpdateStatus.bind(controller)); + + return router; + } +} + +export default AdminController \ No newline at end of file diff --git a/src/controllers/Page/ArticleController.js b/src/controllers/Page/ArticleController.js index 8809814..419ef41 100644 --- a/src/controllers/Page/ArticleController.js +++ b/src/controllers/Page/ArticleController.js @@ -43,7 +43,6 @@ class ArticleController { async show(ctx) { const { slug } = ctx.params - console.log(slug); const article = await ArticleModel.findBySlug(slug) diff --git a/src/controllers/Page/BasePageController.js b/src/controllers/Page/BasePageController.js index 09c5bba..b617f75 100644 --- a/src/controllers/Page/BasePageController.js +++ b/src/controllers/Page/BasePageController.js @@ -1,5 +1,6 @@ import Router from "utils/router.js" import ArticleService from "services/ArticleService.js" +import ContactService from "services/ContactService.js" import { logger } from "@/logger.js" /** @@ -9,6 +10,7 @@ import { logger } from "@/logger.js" class BasePageController { constructor() { this.articleService = new ArticleService() + this.contactService = new ContactService() } // 首页 @@ -63,23 +65,33 @@ class BasePageController { } try { - // 这里可以添加邮件发送逻辑或数据库存储逻辑 - // 目前只是简单的成功响应和日志记录 - logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`) - - // TODO: 可以在这里添加以下功能: - // 1. 发送邮件通知管理员 - // 2. 将联系信息存储到数据库 - // 3. 发送自动回复邮件给用户 + // 获取用户IP和浏览器信息 + const ip_address = ctx.request.ip || ctx.request.header['x-forwarded-for'] || ctx.request.header['x-real-ip']; + const user_agent = ctx.request.header['user-agent']; + + // 存储联系信息到数据库 + const contactData = { + name: name.trim(), + email: email.trim(), + subject: subject.trim(), + message: message.trim(), + ip_address, + user_agent, + status: 'unread' + }; + + await this.contactService.createContact(contactData); + + logger.info(`收到联系表单并已存储: ${name} (${email}) - ${subject}`); ctx.body = { success: true, message: "感谢您的留言,我们会尽快回复您!", - } + }; } catch (error) { - logger.error(`联系表单处理失败: ${error.message}`) - ctx.status = 500 - ctx.body = { success: false, message: "系统错误,请稍后再试" } + logger.error(`联系表单处理失败: ${error.message}`); + ctx.status = 500; + ctx.body = { success: false, message: "系统错误,请稍后再试" }; } } diff --git a/src/db/migrations/20250909000000_create_contacts_table.mjs b/src/db/migrations/20250909000000_create_contacts_table.mjs new file mode 100644 index 0000000..a3a6198 --- /dev/null +++ b/src/db/migrations/20250909000000_create_contacts_table.mjs @@ -0,0 +1,31 @@ +/** + * 联系信息表迁移文件 + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +export const down = function(knex) { + return knex.schema.dropTable('contacts'); +}; \ No newline at end of file diff --git a/src/db/models/ArticleModel.js b/src/db/models/ArticleModel.js index 4bf5fa9..8c7db60 100644 --- a/src/db/models/ArticleModel.js +++ b/src/db/models/ArticleModel.js @@ -35,6 +35,63 @@ class ArticleModel { return db("articles").where("author", author).where("status", "published").orderBy("published_at", "desc") } + static async findByAuthorAll(author) { + return db("articles").where("author", author).orderBy("updated_at", "desc") + } + + static async findByAuthorWithPagination(author, options = {}) { + const { + page = 1, + limit = 10, + status = null, + keyword = null, + orderBy = 'updated_at', + order = 'desc' + } = options; + + let query = db("articles").where("author", author); + + // 状态筛选 + if (status) { + query = query.where("status", status); + } + + // 关键词搜索 + if (keyword && keyword.trim()) { + const searchKeyword = keyword.trim(); + query = query.where(function() { + this.where("title", "like", `%${searchKeyword}%`) + .orWhere("content", "like", `%${searchKeyword}%`) + .orWhere("tags", "like", `%${searchKeyword}%`) + .orWhere("description", "like", `%${searchKeyword}%`); + }); + } + + // 获取总数 + const countQuery = query.clone(); + const totalResult = await countQuery.count("id as count").first(); + const total = totalResult ? parseInt(totalResult.count) : 0; + + // 分页查询 + const offset = (page - 1) * limit; + const articles = await query + .orderBy(orderBy, order) + .limit(limit) + .offset(offset); + + return { + articles, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1 + } + }; + } + static async findByCategory(category) { return db("articles").where("category", category).where("status", "published").orderBy("published_at", "desc") } @@ -95,19 +152,32 @@ class ArticleModel { excerpt = this.generateExcerpt(data.content) } - return db("articles") - .insert({ - ...data, - tags, - slug, - reading_time: readingTime, - excerpt, - status: data.status || "draft", - view_count: 0, - created_at: db.fn.now(), - updated_at: db.fn.now(), - }) - .returning("*") + // 只插入数据库表中存在的字段 + const insertData = { + title: data.title, + content: data.content, + author: data.author, + category: data.category || '', + tags, + keywords: data.keywords || '', + description: data.description || '', + slug, + reading_time: readingTime, + excerpt, + status: data.status || "draft", + view_count: 0, + featured_image: data.featured_image || '', + meta_title: data.meta_title || '', + meta_description: data.meta_description || '', + created_at: db.fn.now(), + updated_at: db.fn.now(), + }; + + const result = await db("articles") + .insert(insertData) + .returning("*"); + + return result[0]; // 返回第一个元素而不是数组 } static async update(id, data) { @@ -150,18 +220,35 @@ class ArticleModel { publishedAt = db.fn.now() } - return db("articles") + // 只更新数据库表中存在的字段 + const updateData = { + updated_at: db.fn.now(), + }; + + // 有选择地更新字段 + if (data.title !== undefined) updateData.title = data.title; + if (data.content !== undefined) updateData.content = data.content; + if (data.category !== undefined) updateData.category = data.category; + if (data.keywords !== undefined) updateData.keywords = data.keywords; + if (data.description !== undefined) updateData.description = data.description; + if (data.featured_image !== undefined) updateData.featured_image = data.featured_image; + if (data.meta_title !== undefined) updateData.meta_title = data.meta_title; + if (data.meta_description !== undefined) updateData.meta_description = data.meta_description; + if (data.status !== undefined) updateData.status = data.status; + + // 处理计算字段 + updateData.tags = tags || current.tags; + updateData.slug = slug || current.slug; + updateData.reading_time = readingTime || current.reading_time; + updateData.excerpt = excerpt || current.excerpt; + updateData.published_at = publishedAt || current.published_at; + + const result = await db("articles") .where("id", id) - .update({ - ...data, - tags: tags || current.tags, - slug: slug || current.slug, - reading_time: readingTime || current.reading_time, - excerpt: excerpt || current.excerpt, - published_at: publishedAt || current.published_at, - updated_at: db.fn.now(), - }) - .returning("*") + .update(updateData) + .returning("*"); + + return result[0]; // 返回第一个元素而不是数组 } static async delete(id) { @@ -173,29 +260,38 @@ class ArticleModel { } static async publish(id) { - return db("articles") + const result = await db("articles") .where("id", id) .update({ status: "published", published_at: db.fn.now(), updated_at: db.fn.now(), }) - .returning("*") + .returning("*"); + + return result[0]; // 返回第一个元素而不是数组 } static async unpublish(id) { - return db("articles") + const result = await db("articles") .where("id", id) .update({ status: "draft", published_at: null, updated_at: db.fn.now(), }) - .returning("*") + .returning("*"); + + return result[0]; // 返回第一个元素而不是数组 } static async incrementViewCount(id) { - return db("articles").where("id", id).increment("view_count", 1).returning("*") + const result = await db("articles") + .where("id", id) + .increment("view_count", 1) + .returning("*"); + + return result[0]; // 返回第一个元素而不是数组 } static async findByDateRange(startDate, endDate) { diff --git a/src/db/models/ContactModel.js b/src/db/models/ContactModel.js new file mode 100644 index 0000000..0c7a871 --- /dev/null +++ b/src/db/models/ContactModel.js @@ -0,0 +1,169 @@ +import db from "../index.js" + +class ContactModel { + /** + * 获取所有联系信息 + * @param {Object} options - 查询选项 + * @param {number} options.page - 页码 + * @param {number} options.limit - 每页数量 + * @param {string} options.status - 状态筛选 + * @param {string} options.orderBy - 排序字段 + * @param {string} options.order - 排序方向 + * @returns {Promise} 联系信息列表 + */ + 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} 联系信息对象 + */ + static async findById(id) { + return db("contacts").where("id", id).first() + } + + /** + * 创建新联系信息 + * @param {Object} data - 联系信息数据 + * @returns {Promise} 插入结果 + */ + 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} 更新结果 + */ + 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} 删除的行数 + */ + static async delete(id) { + return db("contacts").where("id", id).del() + } + + /** + * 根据邮箱查找联系信息 + * @param {string} email - 邮箱地址 + * @returns {Promise} 联系信息列表 + */ + static async findByEmail(email) { + return db("contacts").where("email", email).orderBy('created_at', 'desc') + } + + /** + * 根据状态查找联系信息 + * @param {string} status - 状态 + * @returns {Promise} 联系信息列表 + */ + static async findByStatus(status) { + return db("contacts").where("status", status).orderBy('created_at', 'desc') + } + + /** + * 根据日期范围查找联系信息 + * @param {string} startDate - 开始日期 + * @param {string} endDate - 结束日期 + * @returns {Promise} 联系信息列表 + */ + static async findByDateRange(startDate, endDate) { + return db("contacts") + .whereBetween('created_at', [startDate, endDate]) + .orderBy('created_at', 'desc') + } + + /** + * 获取联系信息统计 + * @returns {Promise} 统计信息 + */ + 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} 总数 + */ + 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} 更新的行数 + */ + static async updateStatusBatch(ids, status) { + return db("contacts") + .whereIn("id", ids) + .update({ + status, + updated_at: db.fn.now() + }); + } +} + +export default ContactModel +export { ContactModel } \ No newline at end of file diff --git a/src/services/ArticleService.js b/src/services/ArticleService.js index 1364348..0862ffa 100644 --- a/src/services/ArticleService.js +++ b/src/services/ArticleService.js @@ -66,6 +66,24 @@ class ArticleService { } } + // 获取用户的所有文章(包括草稿) + async getUserArticles(userId) { + try { + return await ArticleModel.findByAuthorAll(userId) + } catch (error) { + throw new CommonError(`获取用户文章失败: ${error.message}`) + } + } + + // 分页获取用户文章 + async getUserArticlesWithPagination(userId, options = {}) { + try { + return await ArticleModel.findByAuthorWithPagination(userId, options) + } catch (error) { + throw new CommonError(`分页获取用户文章失败: ${error.message}`) + } + } + // 根据分类获取文章 async getArticlesByCategory(category) { try { diff --git a/src/services/ContactService.js b/src/services/ContactService.js new file mode 100644 index 0000000..3f2de87 --- /dev/null +++ b/src/services/ContactService.js @@ -0,0 +1,390 @@ +import ContactModel from "../db/models/ContactModel.js" +import CommonError from "../utils/error/CommonError.js" + +class ContactService { + /** + * 获取所有联系信息 + * @param {Object} options - 查询选项 + * @returns {Promise} 联系信息列表和分页信息 + */ + 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} 联系信息对象 + */ + 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} 创建的联系信息 + */ + 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} 更新后的联系信息 + */ + 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} 删除的行数 + */ + 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} 联系信息列表 + */ + 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} 联系信息列表 + */ + 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} 联系信息列表 + */ + 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} 统计信息 + */ + async getContactStats() { + try { + return await ContactModel.getStats(); + } catch (error) { + throw new CommonError(`获取联系信息统计失败: ${error.message}`); + } + } + + /** + * 批量更新联系信息状态 + * @param {Array} ids - ID数组 + * @param {string} status - 新状态 + * @returns {Promise} 更新的行数 + */ + 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} 删除结果 + */ + 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} 搜索结果和分页信息 + */ + 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} 更新后的联系信息 + */ + async markAsRead(id) { + return await this.updateContactStatus(id, 'read'); + } + + /** + * 标记联系信息为已回复 + * @param {number} id - 联系信息ID + * @returns {Promise} 更新后的联系信息 + */ + async markAsReplied(id) { + return await this.updateContactStatus(id, 'replied'); + } + + /** + * 标记联系信息为未读 + * @param {number} id - 联系信息ID + * @returns {Promise} 更新后的联系信息 + */ + async markAsUnread(id) { + return await this.updateContactStatus(id, 'unread'); + } +} + +export default ContactService \ No newline at end of file diff --git a/src/views/admin/articles/create.pug b/src/views/admin/articles/create.pug new file mode 100644 index 0000000..041089a --- /dev/null +++ b/src/views/admin/articles/create.pug @@ -0,0 +1,225 @@ +extends ../../layouts/admin + +block $$content + .page-container + //- 页面头部 + .page-header + .page-header-left + .breadcrumb + a(href="/admin/articles") 文章管理 + span.breadcrumb-separator / + span.breadcrumb-current 新建文章 + h1.page-title 新建文章 + p.page-subtitle 创建一篇新的文章内容 + + //- 文章表单 + .content-section + form.article-form(method="POST" action="/admin/articles") + .form-grid + //- 左侧主要内容 + .form-main + .form-group + label.form-label(for="title") 文章标题 * + input.form-input( + type="text" + id="title" + name="title" + placeholder="请输入文章标题" + required + maxlength="200" + ) + .form-help 建议标题长度在10-50字之间 + + .form-group + label.form-label(for="slug") URL别名 + input.form-input( + type="text" + id="slug" + name="slug" + placeholder="自动生成或手动输入" + pattern="[a-z0-9-]+" + maxlength="100" + ) + .form-help 用于生成友好的URL,只能包含小写字母、数字和连字符 + + .form-group + label.form-label(for="excerpt") 文章摘要 + textarea.form-textarea( + id="excerpt" + name="excerpt" + placeholder="请输入文章摘要(可选)" + rows="3" + maxlength="500" + ) + .form-help 简短描述文章内容,有助于SEO + + .form-group + label.form-label(for="content") 文章内容 * + textarea.form-textarea.content-editor( + id="content" + name="content" + placeholder="请输入文章内容,支持Markdown格式" + rows="20" + required + ) + .form-help 支持Markdown语法,可直接粘贴Markdown内容 + + //- 右侧设置面板 + .form-sidebar + .sidebar-section + h3.sidebar-title 发布设置 + + .form-group + label.form-label(for="status") 发布状态 + select.form-select(id="status" name="status") + option(value="draft") 📝 保存为草稿 + option(value="published") ✅ 立即发布 + .form-help 草稿状态下文章不会在前台显示 + + .form-group + label.form-label(for="category") 文章分类 + input.form-input( + type="text" + id="category" + name="category" + placeholder="如:技术、生活、随笔" + maxlength="50" + ) + .form-help 用于组织和分类文章 + + .form-group + label.form-label(for="tags") 文章标签 + input.form-input( + type="text" + id="tags" + name="tags" + placeholder="标签1,标签2,标签3" + maxlength="200" + ) + .form-help 多个标签用英文逗号分隔 + + .sidebar-section + h3.sidebar-title 高级设置 + + .form-group + label.form-label(for="featured_image") 特色图片URL + input.form-input( + type="url" + id="featured_image" + name="featured_image" + placeholder="https://example.com/image.jpg" + ) + .form-help 文章封面图片链接 + + //- 表单按钮 + .form-actions + .action-buttons + button.btn.btn-primary(type="submit") 💾 保存文章 + a.btn.btn-outline(href="/admin/articles") 取消 + button.btn.btn-secondary(type="button" onclick="previewArticle()") 👁️ 预览 + +block $$scripts + script. + // 自动生成slug + document.getElementById('title').addEventListener('input', function() { + const title = this.value; + const slugInput = document.getElementById('slug'); + + if (!slugInput.value) { + // 简单的slug生成逻辑 + const slug = title + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + slugInput.value = slug; + } + }); + + // 字符计数 + function setupCharCounter(inputId, maxLength) { + const input = document.getElementById(inputId); + const help = input.nextElementSibling; + + function updateCounter() { + const current = input.value.length; + const remaining = maxLength - current; + const originalText = help.textContent; + + if (remaining < 50) { + help.textContent = `${originalText} (还可输入${remaining}字符)`; + help.style.color = remaining < 10 ? '#ff4757' : '#ffa726'; + } else { + help.style.color = ''; + } + } + + input.addEventListener('input', updateCounter); + updateCounter(); + } + + // 设置字符计数 + setupCharCounter('title', 200); + setupCharCounter('excerpt', 500); + setupCharCounter('tags', 200); + + // 预览功能 + function previewArticle() { + const content = document.getElementById('content').value; + if (!content.trim()) { + alert('请先输入文章内容'); + return; + } + + // 简单的Markdown预览(实际项目中可以使用更完善的Markdown解析器) + const previewWindow = window.open('', '_blank', 'width=800,height=600'); + previewWindow.document.write(` + + + + 文章预览 + + + +

${document.getElementById('title').value || '未设置标题'}

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

${document.getElementById('title').value || '未设置标题'}

+
${content.replace(/\n/g, '
')}
+ + + `); + 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; + } + }); \ No newline at end of file diff --git a/src/views/admin/articles/index.pug b/src/views/admin/articles/index.pug new file mode 100644 index 0000000..ca0f09f --- /dev/null +++ b/src/views/admin/articles/index.pug @@ -0,0 +1,198 @@ +extends ../../layouts/admin + +block $$content + .page-container + //- 页面头部 + .page-header + .page-header-left + h1.page-title 文章管理 + p.page-subtitle 管理您的所有文章内容 + .page-header-right + a.btn.btn-primary(href="/admin/articles/create") ✨ 新建文章 + + //- 筛选和搜索 + .page-filters + form.filter-form(method="GET") + .filter-group + label.filter-label 状态筛选: + select.filter-select(name="status" onchange="this.form.submit()") + option(value="" selected=!filters.status) 全部状态 + option(value="published" selected=filters.status === 'published') 已发布 + option(value="draft" selected=filters.status === 'draft') 草稿 + + .filter-group + label.filter-label 搜索: + .search-box + input.search-input( + type="text" + name="q" + placeholder="搜索文章标题、内容或标签..." + value=filters.q || '' + ) + button.search-btn(type="submit") 🔍 + + if filters.status || filters.q + a.filter-clear(href="/admin/articles") 清除筛选 + + //- 文章列表 + .content-section + if articles && articles.length > 0 + .article-table-container + table.article-table + thead + tr + th.col-title 标题 + th.col-status 状态 + th.col-category 分类 + th.col-date 更新时间 + th.col-actions 操作 + tbody + each article in articles + tr.article-row + td.col-title + .article-title-cell + h3.article-title + a(href=`/admin/articles/${article.id}`)= article.title + if article.excerpt + p.article-summary= article.excerpt.substring(0, 80) + (article.excerpt.length > 80 ? '...' : '') + if article.tags + .article-tags + each tag in article.tags.split(',') + span.tag= tag.trim() + + td.col-status + span.status-badge(class=`status-${article.status}`) + if article.status === 'published' + | ✅ 已发布 + else if article.status === 'draft' + | 📝 草稿 + else + | ❓ #{article.status} + + td.col-category + if article.category + span.category-badge= article.category + else + span.text-muted 未分类 + + td.col-date + .date-info + .primary-date= new Date(article.updated_at).toLocaleDateString('zh-CN') + .secondary-date= new Date(article.updated_at).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}) + + td.col-actions + .action-buttons + a.btn.btn-sm.btn-outline(href=`/admin/articles/${article.id}` title="查看详情") 👁️ + a.btn.btn-sm.btn-outline(href=`/admin/articles/${article.id}/edit` title="编辑文章") ✏️ + if article.status === 'published' + a.btn.btn-sm.btn-outline(href=`/articles/${article.slug}` target="_blank" title="预览文章") 🔗 + button.btn.btn-sm.btn-danger( + onclick=`deleteArticle(${article.id}, '${article.title.replace(/'/g, "\\'")}')` + title="删除文章" + ) 🗑️ + + //- 分页导航 + if pagination.totalPages > 1 + .pagination-container + nav.pagination + if pagination.hasPrev + a.pagination-link(href=`?page=${pagination.page - 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) ‹ 上一页 + + - var start = Math.max(1, pagination.page - 2) + - var end = Math.min(pagination.totalPages, pagination.page + 2) + + if start > 1 + a.pagination-link(href=`?page=1${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 1 + if start > 2 + span.pagination-ellipsis ... + + - for (var i = start; i <= end; i++) + if i === pagination.page + span.pagination-link.active= i + else + a.pagination-link(href=`?page=${i}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= i + + if end < pagination.totalPages + if end < pagination.totalPages - 1 + span.pagination-ellipsis ... + a.pagination-link(href=`?page=${pagination.totalPages}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= pagination.totalPages + + if pagination.hasNext + a.pagination-link(href=`?page=${pagination.page + 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 下一页 › + + //- 统计信息 + .list-footer + .list-stats + | 共 #{pagination.total} 篇文章 + if filters.status || filters.q + |,当前显示筛选结果 + else + //- 空状态 + .empty-state + .empty-icon 📝 + .empty-title 暂无文章 + if filters.status || filters.q + .empty-text 没有找到符合筛选条件的文章 + a.btn.btn-outline(href="/admin/articles") 查看全部文章 + else + .empty-text 您还没有创建任何文章 + a.btn.btn-primary(href="/admin/articles/create") 创建第一篇文章 + +block $$scripts + script. + // 删除文章确认 + function deleteArticle(id, title) { + if (confirm(`确定要删除文章《${title}》吗?此操作不可撤销。`)) { + fetch(`/admin/articles/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // 显示成功消息 + showToast('success', data.message || '文章删除成功'); + // 刷新页面或移除行 + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + showToast('error', data.message || '删除失败'); + } + }) + .catch(error => { + console.error('删除失败:', error); + showToast('error', '删除失败,请稍后重试'); + }); + } + } + + // 显示提示消息 + function showToast(type, message) { + const toast = document.createElement('div'); + toast.className = `admin-toast toast-${type}`; + toast.innerHTML = ` + ${message} + + `; + + 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); + }); + } \ No newline at end of file diff --git a/src/views/admin/articles/show.pug b/src/views/admin/articles/show.pug new file mode 100644 index 0000000..d44b6c9 --- /dev/null +++ b/src/views/admin/articles/show.pug @@ -0,0 +1,158 @@ +extends ../../layouts/admin + +block $$content + .page-container + //- 页面头部 + .page-header + .page-header-left + .breadcrumb + a(href="/admin/articles") 文章管理 + span.breadcrumb-separator / + span.breadcrumb-current= article.title + h1.page-title= article.title + .article-meta + span.meta-item + strong 状态: + span.status-badge(class=`status-${article.status}`) + if article.status === 'published' + | ✅ 已发布 + else if article.status === 'draft' + | 📝 草稿 + if article.category + span.meta-item + strong 分类: + span.category-badge= article.category + span.meta-item + strong 创建时间: + span= new Date(article.created_at).toLocaleString('zh-CN') + span.meta-item + strong 更新时间: + span= new Date(article.updated_at).toLocaleString('zh-CN') + if article.view_count + span.meta-item + strong 阅读量: + span= article.view_count + + .page-header-right + .action-buttons + a.btn.btn-outline(href=`/admin/articles/${article.id}/edit`) ✏️ 编辑 + if article.status === 'published' && article.slug + a.btn.btn-outline(href=`/articles/${article.slug}` target="_blank") 🔗 预览 + button.btn.btn-danger( + onclick=`deleteArticle(${article.id}, '${article.title.replace(/'/g, "\\'")}')` + ) 🗑️ 删除 + + //- 文章内容 + .content-section + .article-view + //- 文章摘要 + if article.excerpt + .article-summary-section + h3.section-title 文章摘要 + .article-summary= article.excerpt + + //- 文章内容 + .article-content-section + h3.section-title 文章内容 + .article-content + if article.content + != article.content.replace(/\n/g, '
') + 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 = ` + ${message} + + `; + + 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); + }); + } \ No newline at end of file diff --git a/src/views/admin/contacts/index.pug b/src/views/admin/contacts/index.pug new file mode 100644 index 0000000..7601465 --- /dev/null +++ b/src/views/admin/contacts/index.pug @@ -0,0 +1,243 @@ +extends ../../layouts/admin + +block $$content + .page-container + //- 页面头部 + .page-header + .page-header-left + h1.page-title 联系信息管理 + p.page-subtitle 管理用户提交的联系表单信息 + .page-header-right + .stats-summary + span.stat-item + .stat-number= pagination.total + .stat-label 总数 + span.stat-item.unread + .stat-number + | #{contacts.filter(c => c.status === 'unread').length} + .stat-label 未读 + + //- 筛选和搜索 + .page-filters + form.filter-form(method="GET") + .filter-group + label.filter-label 状态筛选: + select.filter-select(name="status" onchange="this.form.submit()") + option(value="" selected=!filters.status) 全部状态 + option(value="unread" selected=filters.status === 'unread') 📧 未读 + option(value="read" selected=filters.status === 'read') 👁️ 已读 + option(value="replied" selected=filters.status === 'replied') ✅ 已回复 + + .filter-group + label.filter-label 搜索: + .search-box + input.search-input( + type="text" + name="q" + placeholder="搜索姓名、邮箱、主题或内容..." + value=filters.q || '' + ) + button.search-btn(type="submit") 🔍 + + if filters.status || filters.q + a.filter-clear(href="/admin/contacts") 清除筛选 + + //- 联系信息列表 + .content-section + if contacts && contacts.length > 0 + .contact-table-container + table.contact-table + thead + tr + th.col-contact 联系人信息 + th.col-subject 主题 + th.col-status 状态 + th.col-date 提交时间 + th.col-actions 操作 + tbody + each contact in contacts + tr.contact-row(class=`status-${contact.status}`) + td.col-contact + .contact-info + .contact-name= contact.name + .contact-email= contact.email + if contact.ip_address + .contact-ip IP: #{contact.ip_address} + + td.col-subject + .subject-content + h4.subject-title= contact.subject + .subject-preview= contact.message.substring(0, 80) + (contact.message.length > 80 ? '...' : '') + + td.col-status + span.status-badge(class=`status-${contact.status}`) + if contact.status === 'unread' + | 📧 未读 + else if contact.status === 'read' + | 👁️ 已读 + else if contact.status === 'replied' + | ✅ 已回复 + else + | ❓ #{contact.status} + + td.col-date + .date-info + .primary-date= new Date(contact.created_at).toLocaleDateString('zh-CN') + .secondary-date= new Date(contact.created_at).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}) + + td.col-actions + .action-buttons + a.btn.btn-sm.btn-outline(href=`/admin/contacts/${contact.id}` title="查看详情") 👁️ + + .status-dropdown + select.status-select(onchange=`updateContactStatus(${contact.id}, this.value)`) + option(value="" disabled selected) 状态... + option(value="unread" class=contact.status === 'unread' ? 'disabled' : '') 标记未读 + option(value="read" class=contact.status === 'read' ? 'disabled' : '') 标记已读 + option(value="replied" class=contact.status === 'replied' ? 'disabled' : '') 标记已回复 + + button.btn.btn-sm.btn-danger( + onclick=`deleteContact(${contact.id}, '${contact.name} - ${contact.subject}')` + title="删除联系信息" + ) 🗑️ + + //- 分页导航 + if pagination.totalPages > 1 + .pagination-container + nav.pagination + if pagination.hasPrev + a.pagination-link(href=`?page=${pagination.page - 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) ‹ 上一页 + + - var start = Math.max(1, pagination.page - 2) + - var end = Math.min(pagination.totalPages, pagination.page + 2) + + if start > 1 + a.pagination-link(href=`?page=1${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 1 + if start > 2 + span.pagination-ellipsis ... + + - for (var i = start; i <= end; i++) + if i === pagination.page + span.pagination-link.active= i + else + a.pagination-link(href=`?page=${i}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= i + + if end < pagination.totalPages + if end < pagination.totalPages - 1 + span.pagination-ellipsis ... + a.pagination-link(href=`?page=${pagination.totalPages}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`)= pagination.totalPages + + if pagination.hasNext + a.pagination-link(href=`?page=${pagination.page + 1}${filters.status ? '&status=' + filters.status : ''}${filters.q ? '&q=' + encodeURIComponent(filters.q) : ''}`) 下一页 › + + //- 统计信息 + .list-footer + .list-stats + | 共 #{pagination.total} 条联系信息 + if filters.status || filters.q + |,当前显示筛选结果 + + //- 批量操作(如果需要的话) + .bulk-actions + .bulk-info 提示:点击联系人姓名可以查看完整信息 + else + //- 空状态 + .empty-state + .empty-icon 📧 + .empty-title 暂无联系信息 + if filters.status || filters.q + .empty-text 没有找到符合筛选条件的联系信息 + a.btn.btn-outline(href="/admin/contacts") 查看全部联系信息 + else + .empty-text 还没有用户提交联系表单 + .empty-help 用户可以通过前台的联系页面提交联系信息 + +block $$scripts + script. + // 更新联系信息状态 + function updateContactStatus(id, status) { + if (!status) return; + + fetch(`/admin/contacts/${id}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ status }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast('success', data.message || '状态更新成功'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + showToast('error', data.message || '状态更新失败'); + } + }) + .catch(error => { + console.error('状态更新失败:', error); + showToast('error', '状态更新失败,请稍后重试'); + }); + } + + // 删除联系信息确认 + function deleteContact(id, title) { + if (confirm(`确定要删除联系信息《${title}》吗?此操作不可撤销。`)) { + fetch(`/admin/contacts/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast('success', data.message || '联系信息删除成功'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + showToast('error', data.message || '删除失败'); + } + }) + .catch(error => { + console.error('删除失败:', error); + showToast('error', '删除失败,请稍后重试'); + }); + } + } + + // 显示提示消息 + function showToast(type, message) { + const toast = document.createElement('div'); + toast.className = `admin-toast toast-${type}`; + toast.innerHTML = ` + ${message} + + `; + + 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 = ''; + }); \ No newline at end of file diff --git a/src/views/admin/contacts/show.pug b/src/views/admin/contacts/show.pug new file mode 100644 index 0000000..bd69954 --- /dev/null +++ b/src/views/admin/contacts/show.pug @@ -0,0 +1,229 @@ +extends ../../layouts/admin + +block $$content + .page-container + //- 页面头部 + .page-header + .page-header-left + .breadcrumb + a(href="/admin/contacts") 联系信息管理 + span.breadcrumb-separator / + span.breadcrumb-current= contact.subject + h1.page-title= contact.subject + .contact-meta + span.meta-item + strong 状态: + span.status-badge(class=`status-${contact.status}`) + if contact.status === 'unread' + | 📧 未读 + else if contact.status === 'read' + | 👁️ 已读 + else if contact.status === 'replied' + | ✅ 已回复 + span.meta-item + strong 提交时间: + span= new Date(contact.created_at).toLocaleString('zh-CN') + + .page-header-right + .action-buttons + .status-actions + if contact.status !== 'read' + button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'read')`) 👁️ 标记已读 + if contact.status !== 'replied' + button.btn.btn-success(onclick=`updateContactStatus(${contact.id}, 'replied')`) ✅ 标记已回复 + if contact.status !== 'unread' + button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'unread')`) 📧 标记未读 + + button.btn.btn-danger( + onclick=`deleteContact(${contact.id}, '${contact.name} - ${contact.subject}')` + ) 🗑️ 删除 + + //- 联系信息详情 + .content-section + .contact-details + //- 联系人信息 + .detail-section + h3.section-title 联系人信息 + .info-grid + .info-item + .info-label 姓名 + .info-value= contact.name + .info-item + .info-label 邮箱 + .info-value + a(href=`mailto:${contact.email}`)= contact.email + if contact.ip_address + .info-item + .info-label IP地址 + .info-value= contact.ip_address + if contact.user_agent + .info-item + .info-label 浏览器信息 + .info-value.user-agent= contact.user_agent + + //- 联系主题 + .detail-section + h3.section-title 联系主题 + .subject-content= contact.subject + + //- 联系内容 + .detail-section + h3.section-title 联系内容 + .message-content + .message-text= contact.message + + //- 系统信息 + .detail-section + h3.section-title 系统信息 + .info-grid + .info-item + .info-label ID + .info-value= contact.id + .info-item + .info-label 创建时间 + .info-value= new Date(contact.created_at).toLocaleString('zh-CN') + .info-item + .info-label 更新时间 + .info-value= new Date(contact.updated_at).toLocaleString('zh-CN') + .info-item + .info-label 当前状态 + .info-value + span.status-badge(class=`status-${contact.status}`) + if contact.status === 'unread' + | 📧 未读 + else if contact.status === 'read' + | 👁️ 已读 + else if contact.status === 'replied' + | ✅ 已回复 + + //- 快速回复(可选功能) + .detail-section + h3.section-title 快速操作 + .quick-actions + .action-group + h4.action-title 邮件操作 + .action-buttons + a.btn.btn-primary(href=`mailto:${contact.email}?subject=Re: ${encodeURIComponent(contact.subject)}&body=${encodeURIComponent('您好,感谢您的联系...')}`) 📧 回复邮件 + button.btn.btn-outline(onclick="copyEmail()") 📋 复制邮箱 + + .action-group + h4.action-title 状态操作 + .action-buttons + if contact.status === 'unread' + button.btn.btn-success(onclick=`updateContactStatus(${contact.id}, 'read')`) 标记已读 + button.btn.btn-primary(onclick=`updateContactStatus(${contact.id}, 'replied')`) 标记已回复 + else if contact.status === 'read' + button.btn.btn-primary(onclick=`updateContactStatus(${contact.id}, 'replied')`) 标记已回复 + button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'unread')`) 标记未读 + else if contact.status === 'replied' + button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'read')`) 标记已读 + button.btn.btn-outline(onclick=`updateContactStatus(${contact.id}, 'unread')`) 标记未读 + + //- 导航 + .detail-navigation + a.btn.btn-outline(href="/admin/contacts") ← 返回列表 + +block $$scripts + script. + // 更新联系信息状态 + function updateContactStatus(id, status) { + fetch(`/admin/contacts/${id}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ status }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast('success', data.message || '状态更新成功'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + showToast('error', data.message || '状态更新失败'); + } + }) + .catch(error => { + console.error('状态更新失败:', error); + showToast('error', '状态更新失败,请稍后重试'); + }); + } + + // 删除联系信息确认 + function deleteContact(id, title) { + if (confirm(`确定要删除联系信息《${title}》吗?此操作不可撤销。`)) { + fetch(`/admin/contacts/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast('success', data.message || '联系信息删除成功'); + setTimeout(() => { + window.location.href = '/admin/contacts'; + }, 1000); + } else { + showToast('error', data.message || '删除失败'); + } + }) + .catch(error => { + console.error('删除失败:', error); + showToast('error', '删除失败,请稍后重试'); + }); + } + } + + // 复制邮箱地址 + function copyEmail() { + const email = '#{contact.email}'; + navigator.clipboard.writeText(email).then(() => { + showToast('success', '邮箱地址已复制到剪贴板'); + }).catch(err => { + console.error('复制失败:', err); + // 降级处理 + const textArea = document.createElement('textarea'); + textArea.value = email; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + showToast('success', '邮箱地址已复制到剪贴板'); + } catch (fallbackErr) { + showToast('error', '复制失败,请手动复制'); + } + document.body.removeChild(textArea); + }); + } + + // 显示提示消息 + function showToast(type, message) { + const toast = document.createElement('div'); + toast.className = `admin-toast toast-${type}`; + toast.innerHTML = ` + ${message} + + `; + + 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); + }); + } \ No newline at end of file diff --git a/src/views/admin/dashboard.pug b/src/views/admin/dashboard.pug new file mode 100644 index 0000000..455134c --- /dev/null +++ b/src/views/admin/dashboard.pug @@ -0,0 +1,125 @@ +extends ../layouts/admin + +block $$content + .dashboard + //- 页面标题 + .page-header + h1.page-title 仪表盘 + p.page-subtitle 欢迎回到后台管理系统 + + //- 统计卡片 + .stats-grid + .stats-card + .stats-icon.stats-icon-primary 📧 + .stats-info + .stats-number= contactStats.total + .stats-label 总联系信息 + .stats-detail + .stats-breakdown + span.breakdown-item + span.breakdown-label 未读: + span.breakdown-value= contactStats.unread + span.breakdown-item + span.breakdown-label 已读: + span.breakdown-value= contactStats.read + span.breakdown-item + span.breakdown-label 已回复: + span.breakdown-value= contactStats.replied + + .stats-card + .stats-icon.stats-icon-success 📝 + .stats-info + .stats-number= articleStats.total + .stats-label 我的文章 + .stats-detail + .stats-breakdown + span.breakdown-item + span.breakdown-label 已发布: + span.breakdown-value= articleStats.published + span.breakdown-item + span.breakdown-label 草稿: + span.breakdown-value= articleStats.draft + + //- 主要内容区域 + .dashboard-content + .dashboard-grid + //- 最近联系信息 + .dashboard-section + .section-header + h2.section-title 最近联系信息 + a.section-action(href="/admin/contacts") 查看全部 → + + if recentContacts && recentContacts.length > 0 + .contact-list + each contact in recentContacts + .contact-item(class=`status-${contact.status}`) + .contact-header + .contact-info + strong.contact-name= contact.name + span.contact-email= contact.email + .contact-meta + span.contact-status(class=`status-${contact.status}`) + if contact.status === 'unread' + | 未读 + else if contact.status === 'read' + | 已读 + else if contact.status === 'replied' + | 已回复 + span.contact-date= new Date(contact.created_at).toLocaleDateString('zh-CN') + .contact-subject= contact.subject + .contact-actions + a.btn.btn-sm(href=`/admin/contacts/${contact.id}`) 查看详情 + else + .empty-state + .empty-icon 📧 + .empty-text 暂无联系信息 + + //- 最近文章 + .dashboard-section + .section-header + h2.section-title 最近文章 + a.section-action(href="/admin/articles") 查看全部 → + + if recentArticles && recentArticles.length > 0 + .article-list + each article in recentArticles + .article-item + .article-header + .article-info + strong.article-title= article.title + span.article-status(class=`status-${article.status}`) + if article.status === 'published' + | 已发布 + else if article.status === 'draft' + | 草稿 + .article-meta + span.article-date= new Date(article.updated_at).toLocaleDateString('zh-CN') + if article.excerpt + .article-summary= article.excerpt.substring(0, 100) + (article.excerpt.length > 100 ? '...' : '') + .article-actions + a.btn.btn-sm(href=`/admin/articles/${article.id}`) 查看 + a.btn.btn-sm.btn-outline(href=`/admin/articles/${article.id}/edit`) 编辑 + else + .empty-state + .empty-icon 📝 + .empty-text 暂无文章 + a.btn.btn-primary(href="/admin/articles/create") 创建第一篇文章 + + //- 快速操作 + .quick-actions + h3.quick-actions-title 快速操作 + .quick-actions-grid + a.quick-action-card(href="/admin/articles/create") + .quick-action-icon 📝 + .quick-action-title 新建文章 + .quick-action-desc 创建一篇新的文章 + + a.quick-action-card(href="/admin/articles") + .quick-action-icon 📚 + .quick-action-title 管理文章 + .quick-action-desc 查看和编辑我的文章 + + a.quick-action-card(href="/admin/contacts") + .quick-action-icon 📧 + .quick-action-title 联系信息 + .quick-action-desc 查看用户联系信息 \ No newline at end of file diff --git a/src/views/layouts/admin.pug b/src/views/layouts/admin.pug new file mode 100644 index 0000000..4046c1c --- /dev/null +++ b/src/views/layouts/admin.pug @@ -0,0 +1,128 @@ +include utils.pug + +doctype html +html(lang="zh-CN") + head + block $$head + title #{title ? title + ' - ' : ''}后台管理 - #{$site && $site.site_title || ''} + meta(name="description" content="后台管理系统") + meta(charset="utf-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + +css('lib/reset.css') + +css('css/admin.css') + +js('lib/htmx.min.js') + +js('lib/tailwindcss.3.4.17.js') + body.admin-body + #admin-app + //- 顶部导航栏 + header.admin-header + .admin-header-left + .admin-logo + a(href="/admin") 后台管理 + .admin-header-center + .admin-breadcrumb + span.breadcrumb-item + a(href="/admin") 首页 + if title + span.breadcrumb-separator / + span.breadcrumb-item= title + .admin-header-right + .admin-user-menu + .dropdown + button.dropdown-trigger + span= $user ? $user.name || $user.username : '用户' + i.dropdown-arrow ▼ + .dropdown-menu + a.dropdown-item(href="/profile") 个人资料 + a.dropdown-item(href="/") 前台首页 + .dropdown-divider + a.dropdown-item(href="/logout") 退出登录 + + //- 主要内容区域 + .admin-main + //- 左侧导航 + aside.admin-sidebar + nav.admin-nav + .nav-section + .nav-title 主要功能 + ul.nav-list + li.nav-item + a.nav-link(href="/admin" class=title === '后台管理' ? 'active' : '') + i.nav-icon 📊 + span 仪表盘 + li.nav-item + a.nav-link(href="/admin/articles" class=title === '文章管理' ? 'active' : '') + i.nav-icon 📝 + span 文章管理 + li.nav-item + a.nav-link(href="/admin/contacts" class=title === '联系信息管理' ? 'active' : '') + i.nav-icon 📧 + span 联系信息 + + //- 右侧内容区域 + main.admin-content + if toast + .admin-toast(class=`toast-${toast.type}`) + span= toast.message + button.toast-close × + + .admin-content-inner + block $$content + + //- JavaScript + script(src="/js/admin.js") + block $$scripts + + //- 处理Toast消息 + script. + // Toast消息处理 + const toast = document.querySelector('.admin-toast'); + if (toast) { + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => { + toast.remove(); + }, 300); + }, 3000); + + const closeBtn = toast.querySelector('.toast-close'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + toast.style.opacity = '0'; + setTimeout(() => { + toast.remove(); + }, 300); + }); + } + } + + // 下拉菜单处理 + const dropdown = document.querySelector('.dropdown'); + if (dropdown) { + const trigger = dropdown.querySelector('.dropdown-trigger'); + const menu = dropdown.querySelector('.dropdown-menu'); + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.classList.toggle('active'); + }); + + document.addEventListener('click', () => { + dropdown.classList.remove('active'); + }); + } + + // 移动端侧边栏切换 + function toggleSidebar() { + document.body.classList.toggle('sidebar-open'); + } + + // 响应式处理 + function handleResize() { + if (window.innerWidth > 768) { + document.body.classList.remove('sidebar-open'); + } + } + + window.addEventListener('resize', handleResize); + handleResize(); \ No newline at end of file diff --git a/src/views/page/articles/article.pug b/src/views/page/articles/article.pug index a92df10..d491eff 100644 --- a/src/views/page/articles/article.pug +++ b/src/views/page/articles/article.pug @@ -37,7 +37,7 @@ block pageContent .prose.prose-lg.max-w-none.mb-8.markdown-content(class="prose-pre:bg-gray-100 prose-pre:p-4 prose-pre:rounded-lg prose-code:text-blue-600 prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-img:rounded-lg prose-img:shadow-md") != article.content - if article.keywords || article.description + if article.keywords || article.excerpt .bg-gray-50.rounded-lg.p-6.mb-8 if article.keywords .mb-4 @@ -45,9 +45,9 @@ block pageContent .flex.flex-wrap.gap-2 each keyword in article.keywords.split(',') span.bg-white.px-3.py-1.rounded-full.text-sm= keyword.trim() - if article.description - h3.text-lg.font-semibold.mb-2 描述 - p.text-gray-600= article.description + if article.excerpt + h3.text-lg.font-semibold.mb-2 摘要 + p.text-gray-600= article.excerpt if relatedArticles && relatedArticles.length section.border-t.pt-8.mt-8 diff --git a/src/views/page/index/index.pug b/src/views/page/index/index.pug index c543dd2..a0a5446 100644 --- a/src/views/page/index/index.pug +++ b/src/views/page/index/index.pug @@ -31,7 +31,7 @@ mixin card(blog) class="text-gray-600 text-base mb-4 line-clamp-2" style="height: 2.8em; overflow: hidden;" ) - | #{blog.description} + | #{blog.excerpt} a(href="/articles/"+blog.slug class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 → mixin empty()