56 changed files with 2007 additions and 4115 deletions
@ -1,355 +0,0 @@ |
|||||
# 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攻击防护测试 |
|
||||
- 数据验证测试 |
|
||||
@ -1,617 +0,0 @@ |
|||||
# 数据库模块检查与优化设计文档 |
|
||||
|
|
||||
## 概述 |
|
||||
|
|
||||
本文档分析 Koa3 项目的数据库模块存在的问题,并提供优化方案。通过深入分析代码结构、模型设计、查询缓存和错误处理机制,识别潜在问题并提出改进建议。 |
|
||||
|
|
||||
## 技术栈分析 |
|
||||
|
|
||||
- **数据库**: SQLite3 |
|
||||
- **ORM框架**: Knex.js |
|
||||
- **缓存机制**: 内存缓存(自定义实现) |
|
||||
- **项目类型**: 后端应用(Node.js + Koa3) |
|
||||
|
|
||||
## 架构分析 |
|
||||
|
|
||||
### 当前架构结构 |
|
||||
|
|
||||
```mermaid |
|
||||
graph TB |
|
||||
A[应用层] --> B[模型层] |
|
||||
B --> C[数据库连接层] |
|
||||
C --> D[SQLite数据库] |
|
||||
|
|
||||
B --> E[查询缓存层] |
|
||||
E --> F[内存缓存] |
|
||||
|
|
||||
C --> G[Knex QueryBuilder] |
|
||||
G --> H[迁移系统] |
|
||||
G --> I[种子数据] |
|
||||
``` |
|
||||
|
|
||||
### 数据模型关系图 |
|
||||
|
|
||||
```mermaid |
|
||||
erDiagram |
|
||||
Users { |
|
||||
int id PK |
|
||||
string username |
|
||||
string email UK |
|
||||
string password |
|
||||
string role |
|
||||
string phone |
|
||||
int age |
|
||||
string name |
|
||||
text bio |
|
||||
string avatar |
|
||||
string status |
|
||||
timestamp created_at |
|
||||
timestamp updated_at |
|
||||
} |
|
||||
|
|
||||
Articles { |
|
||||
int id PK |
|
||||
string title |
|
||||
text content |
|
||||
string author |
|
||||
string category |
|
||||
string tags |
|
||||
string keywords |
|
||||
string description |
|
||||
string status |
|
||||
timestamp published_at |
|
||||
int view_count |
|
||||
string featured_image |
|
||||
text excerpt |
|
||||
int reading_time |
|
||||
string meta_title |
|
||||
text meta_description |
|
||||
string slug UK |
|
||||
timestamp created_at |
|
||||
timestamp updated_at |
|
||||
} |
|
||||
|
|
||||
Bookmarks { |
|
||||
int id PK |
|
||||
int user_id FK |
|
||||
string title |
|
||||
string url |
|
||||
text description |
|
||||
timestamp created_at |
|
||||
timestamp updated_at |
|
||||
} |
|
||||
|
|
||||
SiteConfig { |
|
||||
int id PK |
|
||||
string key UK |
|
||||
text value |
|
||||
timestamp created_at |
|
||||
timestamp updated_at |
|
||||
} |
|
||||
|
|
||||
Contacts { |
|
||||
int id PK |
|
||||
string name |
|
||||
string email |
|
||||
string subject |
|
||||
text message |
|
||||
string ip_address |
|
||||
text user_agent |
|
||||
string status |
|
||||
timestamp created_at |
|
||||
timestamp updated_at |
|
||||
} |
|
||||
|
|
||||
Users ||--o{ Bookmarks : "拥有" |
|
||||
``` |
|
||||
|
|
||||
## 问题识别与分析 |
|
||||
|
|
||||
### 1. 数据库连接问题 |
|
||||
|
|
||||
#### 问题描述 |
|
||||
- 连接池配置不合理,SQLite设置为最大1个连接,在高并发场景下可能成为瓶颈 |
|
||||
- 缺少连接重试机制和错误恢复策略 |
|
||||
- 没有健康检查机制 |
|
||||
|
|
||||
#### 影响评估 |
|
||||
- **性能影响**: 高并发场景下连接竞争导致性能下降 |
|
||||
- **稳定性风险**: 连接异常时缺少恢复机制 |
|
||||
|
|
||||
### 2. 模型设计问题 |
|
||||
|
|
||||
#### 问题描述 |
|
||||
- 模型方法返回值不一致,部分返回数组,部分返回对象 |
|
||||
- 缺少统一的错误处理机制 |
|
||||
- 模型之间缺少关联查询方法 |
|
||||
- 批量操作支持不足 |
|
||||
|
|
||||
#### 影响评估 |
|
||||
- **开发效率**: 不一致的API增加开发复杂度 |
|
||||
- **维护成本**: 缺少统一规范导致维护困难 |
|
||||
|
|
||||
### 3. 查询缓存问题 |
|
||||
|
|
||||
#### 问题描述 |
|
||||
- 缓存键生成策略不合理,可能产生冲突 |
|
||||
- 缺少缓存失效策略和一致性保证 |
|
||||
- 没有缓存命中率监控 |
|
||||
|
|
||||
#### 影响评估 |
|
||||
- **数据一致性**: 缓存与数据库数据不同步 |
|
||||
- **内存泄漏**: 缓存无限增长可能导致内存问题 |
|
||||
|
|
||||
### 4. 事务处理问题 |
|
||||
|
|
||||
#### 问题描述 |
|
||||
- 模型方法缺少事务支持 |
|
||||
- 没有原子操作保证 |
|
||||
- 复杂业务逻辑缺少事务包装 |
|
||||
|
|
||||
#### 影响评估 |
|
||||
- **数据完整性**: 并发操作可能导致数据不一致 |
|
||||
- **业务逻辑**: 复杂操作缺少原子性保证 |
|
||||
|
|
||||
### 5. 索引优化问题 |
|
||||
|
|
||||
#### 问题描述 |
|
||||
- 部分查询缺少合适的索引 |
|
||||
- 复合索引设计不合理 |
|
||||
- 缺少查询性能监控 |
|
||||
|
|
||||
#### 影响评估 |
|
||||
- **查询性能**: 缺少索引导致查询缓慢 |
|
||||
- **扩展性**: 数据量增长时性能急剧下降 |
|
||||
|
|
||||
## 优化方案设计 |
|
||||
|
|
||||
### 1. 数据库连接优化 |
|
||||
|
|
||||
#### 连接池配置改进 |
|
||||
```javascript |
|
||||
// knexfile.mjs 优化配置 |
|
||||
export default { |
|
||||
development: { |
|
||||
client: "sqlite3", |
|
||||
connection: { |
|
||||
filename: "./database/development.sqlite3", |
|
||||
}, |
|
||||
pool: { |
|
||||
min: 1, |
|
||||
max: 3, // 适当增加连接数 |
|
||||
acquireTimeoutMillis: 60000, |
|
||||
createTimeoutMillis: 30000, |
|
||||
destroyTimeoutMillis: 5000, |
|
||||
idleTimeoutMillis: 30000, |
|
||||
reapIntervalMillis: 1000, |
|
||||
createRetryIntervalMillis: 200, |
|
||||
afterCreate: (conn, done) => { |
|
||||
conn.run("PRAGMA journal_mode = WAL", done) |
|
||||
conn.run("PRAGMA synchronous = NORMAL", done) |
|
||||
conn.run("PRAGMA cache_size = 1000", done) |
|
||||
conn.run("PRAGMA temp_store = MEMORY", done) |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### 健康检查机制 |
|
||||
```javascript |
|
||||
// db/index.js 添加健康检查 |
|
||||
export const checkHealth = async () => { |
|
||||
try { |
|
||||
await db.raw("SELECT 1") |
|
||||
return { status: "healthy", timestamp: new Date() } |
|
||||
} catch (error) { |
|
||||
return { status: "unhealthy", error: error.message, timestamp: new Date() } |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 2. 模型设计优化 |
|
||||
|
|
||||
#### 统一基础模型类 |
|
||||
```javascript |
|
||||
// db/models/BaseModel.js |
|
||||
class BaseModel { |
|
||||
static get tableName() { |
|
||||
throw new Error("tableName must be defined") |
|
||||
} |
|
||||
|
|
||||
static async findById(id) { |
|
||||
const result = await db(this.tableName).where("id", id).first() |
|
||||
return result || null |
|
||||
} |
|
||||
|
|
||||
static async findAll(options = {}) { |
|
||||
const { page = 1, limit = 10, orderBy = "id", order = "desc" } = options |
|
||||
const offset = (page - 1) * limit |
|
||||
|
|
||||
return db(this.tableName) |
|
||||
.orderBy(orderBy, order) |
|
||||
.limit(limit) |
|
||||
.offset(offset) |
|
||||
} |
|
||||
|
|
||||
static async create(data) { |
|
||||
const [result] = await db(this.tableName) |
|
||||
.insert({ |
|
||||
...data, |
|
||||
created_at: db.fn.now(), |
|
||||
updated_at: db.fn.now(), |
|
||||
}) |
|
||||
.returning("*") |
|
||||
return result |
|
||||
} |
|
||||
|
|
||||
static async update(id, data) { |
|
||||
const [result] = await db(this.tableName) |
|
||||
.where("id", id) |
|
||||
.update({ |
|
||||
...data, |
|
||||
updated_at: db.fn.now(), |
|
||||
}) |
|
||||
.returning("*") |
|
||||
return result |
|
||||
} |
|
||||
|
|
||||
static async delete(id) { |
|
||||
return db(this.tableName).where("id", id).del() |
|
||||
} |
|
||||
|
|
||||
static async count(conditions = {}) { |
|
||||
const result = await db(this.tableName).where(conditions).count("id as count").first() |
|
||||
return parseInt(result.count) |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### 关联查询方法 |
|
||||
```javascript |
|
||||
// 扩展模型关联查询 |
|
||||
class ArticleModel extends BaseModel { |
|
||||
static get tableName() { return "articles" } |
|
||||
|
|
||||
// 获取作者相关文章 |
|
||||
static async findByAuthorWithProfile(author) { |
|
||||
return db(this.tableName) |
|
||||
.select("articles.*", "users.name as author_name", "users.avatar as author_avatar") |
|
||||
.leftJoin("users", "articles.author", "users.username") |
|
||||
.where("articles.author", author) |
|
||||
.where("articles.status", "published") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
class BookmarkModel extends BaseModel { |
|
||||
static get tableName() { return "bookmarks" } |
|
||||
|
|
||||
// 获取用户书签(包含用户信息) |
|
||||
static async findByUserWithProfile(userId) { |
|
||||
return db(this.tableName) |
|
||||
.select("bookmarks.*", "users.username", "users.name") |
|
||||
.leftJoin("users", "bookmarks.user_id", "users.id") |
|
||||
.where("bookmarks.user_id", userId) |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 3. 查询缓存优化 |
|
||||
|
|
||||
#### 改进缓存键生成策略 |
|
||||
```javascript |
|
||||
// db/index.js 缓存优化 |
|
||||
const getCacheKeyForBuilder = (builder) => { |
|
||||
if (builder._customCacheKey) return String(builder._customCacheKey) |
|
||||
|
|
||||
// 改进键生成策略 |
|
||||
const sql = builder.toString() |
|
||||
const tableName = builder._single.table || 'unknown' |
|
||||
const hash = require('crypto').createHash('md5').update(sql).digest('hex') |
|
||||
|
|
||||
return `${tableName}:${hash}` |
|
||||
} |
|
||||
|
|
||||
// 添加缓存统计 |
|
||||
export const getCacheStats = () => { |
|
||||
let valid = 0 |
|
||||
let expired = 0 |
|
||||
let totalSize = 0 |
|
||||
|
|
||||
for (const [key, entry] of queryCache.entries()) { |
|
||||
if (isExpired(entry)) { |
|
||||
expired++ |
|
||||
} else { |
|
||||
valid++ |
|
||||
totalSize += JSON.stringify(entry.value).length |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
totalKeys: queryCache.size, |
|
||||
validKeys: valid, |
|
||||
expiredKeys: expired, |
|
||||
totalSize, |
|
||||
hitRate: valid / (valid + expired) || 0 |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### 缓存一致性策略 |
|
||||
```javascript |
|
||||
// 数据变更时自动清理相关缓存 |
|
||||
buildKnex.QueryBuilder.extend("invalidateCache", function() { |
|
||||
const tableName = this._single.table |
|
||||
if (tableName) { |
|
||||
DbQueryCache.clearByPrefix(`${tableName}:`) |
|
||||
} |
|
||||
return this |
|
||||
}) |
|
||||
|
|
||||
// 在模型的 CUD 操作后自动清理缓存 |
|
||||
class BaseModel { |
|
||||
static async create(data) { |
|
||||
const result = await db(this.tableName).insert(data).returning("*") |
|
||||
await db(this.tableName).invalidateCache() |
|
||||
return result[0] |
|
||||
} |
|
||||
|
|
||||
static async update(id, data) { |
|
||||
const result = await db(this.tableName) |
|
||||
.where("id", id) |
|
||||
.update(data) |
|
||||
.returning("*") |
|
||||
await db(this.tableName).invalidateCache() |
|
||||
return result[0] |
|
||||
} |
|
||||
|
|
||||
static async delete(id) { |
|
||||
const result = await db(this.tableName).where("id", id).del() |
|
||||
await db(this.tableName).invalidateCache() |
|
||||
return result |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 4. 事务处理优化 |
|
||||
|
|
||||
#### 事务工具函数 |
|
||||
```javascript |
|
||||
// db/transaction.js |
|
||||
export const withTransaction = async (callback) => { |
|
||||
const trx = await db.transaction() |
|
||||
try { |
|
||||
const result = await callback(trx) |
|
||||
await trx.commit() |
|
||||
return result |
|
||||
} catch (error) { |
|
||||
await trx.rollback() |
|
||||
throw error |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 使用示例 |
|
||||
export const createUserWithProfile = async (userData, profileData) => { |
|
||||
return withTransaction(async (trx) => { |
|
||||
const [user] = await trx("users").insert(userData).returning("*") |
|
||||
const [profile] = await trx("user_profiles") |
|
||||
.insert({ ...profileData, user_id: user.id }) |
|
||||
.returning("*") |
|
||||
return { user, profile } |
|
||||
}) |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### 批量操作优化 |
|
||||
```javascript |
|
||||
// 批量插入优化 |
|
||||
class BaseModel { |
|
||||
static async createMany(dataArray, batchSize = 100) { |
|
||||
const results = [] |
|
||||
for (let i = 0; i < dataArray.length; i += batchSize) { |
|
||||
const batch = dataArray.slice(i, i + batchSize) |
|
||||
const batchResults = await db(this.tableName) |
|
||||
.insert(batch) |
|
||||
.returning("*") |
|
||||
results.push(...batchResults) |
|
||||
} |
|
||||
await db(this.tableName).invalidateCache() |
|
||||
return results |
|
||||
} |
|
||||
|
|
||||
static async updateMany(conditions, data) { |
|
||||
const result = await db(this.tableName) |
|
||||
.where(conditions) |
|
||||
.update({ ...data, updated_at: db.fn.now() }) |
|
||||
await db(this.tableName).invalidateCache() |
|
||||
return result |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 5. 索引优化建议 |
|
||||
|
|
||||
#### 添加必要索引 |
|
||||
```javascript |
|
||||
// 新增迁移文件:add_performance_indexes.mjs |
|
||||
export const up = async (knex) => { |
|
||||
// 用户表索引 |
|
||||
await knex.schema.alterTable("users", (table) => { |
|
||||
table.index(["email"]) |
|
||||
table.index(["username"]) |
|
||||
table.index(["status", "created_at"]) |
|
||||
}) |
|
||||
|
|
||||
// 文章表索引 |
|
||||
await knex.schema.alterTable("articles", (table) => { |
|
||||
table.index(["author", "status"]) |
|
||||
table.index(["category", "published_at"]) |
|
||||
table.index(["status", "view_count"]) |
|
||||
table.index(["tags"]) // 用于标签搜索 |
|
||||
}) |
|
||||
|
|
||||
// 书签表索引 |
|
||||
await knex.schema.alterTable("bookmarks", (table) => { |
|
||||
table.index(["user_id", "created_at"]) |
|
||||
table.index(["url"]) // 用于URL查重 |
|
||||
}) |
|
||||
|
|
||||
// 联系人表索引 |
|
||||
await knex.schema.alterTable("contacts", (table) => { |
|
||||
table.index(["email", "created_at"]) |
|
||||
table.index(["status", "created_at"]) |
|
||||
}) |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 6. 错误处理优化 |
|
||||
|
|
||||
#### 统一错误处理机制 |
|
||||
```javascript |
|
||||
// db/errors.js |
|
||||
export class DatabaseError extends Error { |
|
||||
constructor(message, code, originalError) { |
|
||||
super(message) |
|
||||
this.name = "DatabaseError" |
|
||||
this.code = code |
|
||||
this.originalError = originalError |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export const handleDatabaseError = (error) => { |
|
||||
if (error.code === "SQLITE_CONSTRAINT") { |
|
||||
return new DatabaseError("数据约束违反", "CONSTRAINT_VIOLATION", error) |
|
||||
} |
|
||||
if (error.code === "SQLITE_BUSY") { |
|
||||
return new DatabaseError("数据库忙,请稍后重试", "DATABASE_BUSY", error) |
|
||||
} |
|
||||
return new DatabaseError("数据库操作失败", "DATABASE_ERROR", error) |
|
||||
} |
|
||||
|
|
||||
// 在模型中使用 |
|
||||
class BaseModel { |
|
||||
static async findById(id) { |
|
||||
try { |
|
||||
return await db(this.tableName).where("id", id).first() |
|
||||
} catch (error) { |
|
||||
throw handleDatabaseError(error) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 7. 性能监控优化 |
|
||||
|
|
||||
#### 查询性能监控 |
|
||||
```javascript |
|
||||
// db/monitor.js |
|
||||
const queryStats = new Map() |
|
||||
|
|
||||
export const logQuery = (sql, duration) => { |
|
||||
const key = sql.split(' ')[0].toUpperCase() // SELECT, INSERT, UPDATE, DELETE |
|
||||
if (!queryStats.has(key)) { |
|
||||
queryStats.set(key, { count: 0, totalTime: 0, avgTime: 0 }) |
|
||||
} |
|
||||
|
|
||||
const stats = queryStats.get(key) |
|
||||
stats.count++ |
|
||||
stats.totalTime += duration |
|
||||
stats.avgTime = stats.totalTime / stats.count |
|
||||
} |
|
||||
|
|
||||
export const getQueryStats = () => { |
|
||||
return Object.fromEntries(queryStats) |
|
||||
} |
|
||||
|
|
||||
// 在 knex 配置中添加查询日志 |
|
||||
export default { |
|
||||
development: { |
|
||||
// ... 其他配置 |
|
||||
log: { |
|
||||
warn(message) { |
|
||||
console.warn(message) |
|
||||
}, |
|
||||
error(message) { |
|
||||
console.error(message) |
|
||||
}, |
|
||||
deprecate(message) { |
|
||||
console.log(message) |
|
||||
}, |
|
||||
debug(message) { |
|
||||
if (message.sql) { |
|
||||
const duration = message.bindings ? message.duration : 0 |
|
||||
logQuery(message.sql, duration) |
|
||||
} |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
## 测试策略 |
|
||||
|
|
||||
### 单元测试框架 |
|
||||
```javascript |
|
||||
// tests/models/BaseModel.test.js |
|
||||
import { expect } from 'chai' |
|
||||
import { BaseModel } from '../src/db/models/BaseModel.js' |
|
||||
|
|
||||
describe('BaseModel', () => { |
|
||||
it('应该正确创建记录', async () => { |
|
||||
const data = { name: 'test' } |
|
||||
const result = await TestModel.create(data) |
|
||||
expect(result).to.have.property('id') |
|
||||
expect(result.name).to.equal('test') |
|
||||
}) |
|
||||
|
|
||||
it('应该正确处理事务', async () => { |
|
||||
await expect( |
|
||||
withTransaction(async (trx) => { |
|
||||
await trx('test_table').insert({ name: 'test' }) |
|
||||
throw new Error('回滚测试') |
|
||||
}) |
|
||||
).to.be.rejected |
|
||||
|
|
||||
const count = await TestModel.count() |
|
||||
expect(count).to.equal(0) |
|
||||
}) |
|
||||
}) |
|
||||
``` |
|
||||
|
|
||||
### 性能测试 |
|
||||
```javascript |
|
||||
// tests/performance/cache.test.js |
|
||||
describe('缓存性能测试', () => { |
|
||||
it('缓存命中率应该大于80%', async () => { |
|
||||
// 执行大量查询 |
|
||||
for (let i = 0; i < 1000; i++) { |
|
||||
await ArticleModel.findById(1).cache(60000) |
|
||||
} |
|
||||
|
|
||||
const stats = getCacheStats() |
|
||||
expect(stats.hitRate).to.be.greaterThan(0.8) |
|
||||
}) |
|
||||
}) |
|
||||
``` |
|
||||
|
|
||||
## 迁移计划 |
|
||||
|
|
||||
### 阶段1: 基础优化(1-2周) |
|
||||
1. 修复数据库连接配置 |
|
||||
2. 统一模型返回值格式 |
|
||||
3. 添加基础错误处理 |
|
||||
|
|
||||
### 阶段2: 功能增强(2-3周) |
|
||||
1. 实现统一基础模型类 |
|
||||
2. 添加关联查询方法 |
|
||||
3. 优化查询缓存机制 |
|
||||
|
|
||||
### 阶段3: 性能优化(1-2周) |
|
||||
1. 添加必要索引 |
|
||||
2. 实现事务支持 |
|
||||
3. 添加性能监控 |
|
||||
|
|
||||
### 阶段4: 测试与验证(1周) |
|
||||
1. 编写单元测试 |
|
||||
2. 性能基准测试 |
|
||||
3. 生产环境验证 |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,5 +1,5 @@ |
|||||
import db from "../index.js" |
import db from "@/db" |
||||
import { logger } from "../../logger.js" |
import { logger } from "@/logger.js" |
||||
import { BaseSingleton } from "@/utils/BaseSingleton" |
import { BaseSingleton } from "@/utils/BaseSingleton" |
||||
|
|
||||
/** |
/** |
||||
@ -1,190 +0,0 @@ |
|||||
# 数据库模型文档 |
|
||||
|
|
||||
## ArticleModel |
|
||||
|
|
||||
ArticleModel 是一个功能完整的文章管理模型,提供了丰富的CRUD操作和查询方法。 |
|
||||
|
|
||||
### 主要特性 |
|
||||
|
|
||||
- ✅ 完整的CRUD操作 |
|
||||
- ✅ 文章状态管理(草稿、已发布、已归档) |
|
||||
- ✅ 自动生成slug、摘要和阅读时间 |
|
||||
- ✅ 标签和分类管理 |
|
||||
- ✅ SEO优化支持 |
|
||||
- ✅ 浏览量统计 |
|
||||
- ✅ 相关文章推荐 |
|
||||
- ✅ 全文搜索功能 |
|
||||
|
|
||||
### 数据库字段 |
|
||||
|
|
||||
| 字段名 | 类型 | 说明 | |
|
||||
|--------|------|------| |
|
||||
| id | integer | 主键,自增 | |
|
||||
| title | string | 文章标题(必填) | |
|
||||
| content | text | 文章内容(必填) | |
|
||||
| author | string | 作者 | |
|
||||
| category | string | 分类 | |
|
||||
| tags | string | 标签(逗号分隔) | |
|
||||
| keywords | string | SEO关键词 | |
|
||||
| description | string | 文章描述 | |
|
||||
| status | string | 状态:draft/published/archived | |
|
||||
| published_at | timestamp | 发布时间 | |
|
||||
| view_count | integer | 浏览量 | |
|
||||
| featured_image | string | 特色图片 | |
|
||||
| excerpt | text | 文章摘要 | |
|
||||
| reading_time | integer | 阅读时间(分钟) | |
|
||||
| meta_title | string | SEO标题 | |
|
||||
| meta_description | text | SEO描述 | |
|
||||
| slug | string | URL友好的标识符 | |
|
||||
| created_at | timestamp | 创建时间 | |
|
||||
| updated_at | timestamp | 更新时间 | |
|
||||
|
|
||||
### 基本用法 |
|
||||
|
|
||||
```javascript |
|
||||
import { ArticleModel } from '../models/ArticleModel.js' |
|
||||
|
|
||||
// 创建文章 |
|
||||
const article = await ArticleModel.create({ |
|
||||
title: "我的第一篇文章", |
|
||||
content: "这是文章内容...", |
|
||||
author: "张三", |
|
||||
category: "技术", |
|
||||
tags: "JavaScript, Node.js, 教程" |
|
||||
}) |
|
||||
|
|
||||
// 查找所有已发布的文章 |
|
||||
const publishedArticles = await ArticleModel.findPublished() |
|
||||
|
|
||||
// 根据ID查找文章 |
|
||||
const article = await ArticleModel.findById(1) |
|
||||
|
|
||||
// 更新文章 |
|
||||
await ArticleModel.update(1, { |
|
||||
title: "更新后的标题", |
|
||||
content: "更新后的内容" |
|
||||
}) |
|
||||
|
|
||||
// 发布文章 |
|
||||
await ArticleModel.publish(1) |
|
||||
|
|
||||
// 删除文章 |
|
||||
await ArticleModel.delete(1) |
|
||||
``` |
|
||||
|
|
||||
### 查询方法 |
|
||||
|
|
||||
#### 基础查询 |
|
||||
- `findAll()` - 查找所有文章 |
|
||||
- `findById(id)` - 根据ID查找文章 |
|
||||
- `findBySlug(slug)` - 根据slug查找文章 |
|
||||
- `findPublished()` - 查找所有已发布的文章 |
|
||||
- `findDrafts()` - 查找所有草稿文章 |
|
||||
|
|
||||
#### 分类查询 |
|
||||
- `findByAuthor(author)` - 根据作者查找文章 |
|
||||
- `findByCategory(category)` - 根据分类查找文章 |
|
||||
- `findByTags(tags)` - 根据标签查找文章 |
|
||||
|
|
||||
#### 搜索功能 |
|
||||
- `searchByKeyword(keyword)` - 关键词搜索(标题、内容、关键词、描述、摘要) |
|
||||
|
|
||||
#### 统计功能 |
|
||||
- `getArticleCount()` - 获取文章总数 |
|
||||
- `getPublishedArticleCount()` - 获取已发布文章数量 |
|
||||
- `getArticleCountByCategory()` - 按分类统计文章数量 |
|
||||
- `getArticleCountByStatus()` - 按状态统计文章数量 |
|
||||
|
|
||||
#### 推荐功能 |
|
||||
- `getRecentArticles(limit)` - 获取最新文章 |
|
||||
- `getPopularArticles(limit)` - 获取热门文章 |
|
||||
- `getFeaturedArticles(limit)` - 获取特色文章 |
|
||||
- `getRelatedArticles(articleId, limit)` - 获取相关文章 |
|
||||
|
|
||||
#### 高级查询 |
|
||||
- `findByDateRange(startDate, endDate)` - 按日期范围查找文章 |
|
||||
- `incrementViewCount(id)` - 增加浏览量 |
|
||||
|
|
||||
### 状态管理 |
|
||||
|
|
||||
文章支持三种状态: |
|
||||
- `draft` - 草稿状态 |
|
||||
- `published` - 已发布状态 |
|
||||
- `archived` - 已归档状态 |
|
||||
|
|
||||
```javascript |
|
||||
// 发布文章 |
|
||||
await ArticleModel.publish(articleId) |
|
||||
|
|
||||
// 取消发布 |
|
||||
await ArticleModel.unpublish(articleId) |
|
||||
``` |
|
||||
|
|
||||
### 自动功能 |
|
||||
|
|
||||
#### 自动生成slug |
|
||||
如果未提供slug,系统会自动根据标题生成: |
|
||||
```javascript |
|
||||
// 标题: "我的第一篇文章" |
|
||||
// 自动生成slug: "我的第一篇文章" |
|
||||
``` |
|
||||
|
|
||||
#### 自动计算阅读时间 |
|
||||
基于内容长度自动计算阅读时间(假设每分钟200个单词) |
|
||||
|
|
||||
#### 自动生成摘要 |
|
||||
如果未提供摘要,系统会自动从内容中提取前150个字符 |
|
||||
|
|
||||
### 标签管理 |
|
||||
|
|
||||
标签支持逗号分隔的格式,系统会自动处理: |
|
||||
```javascript |
|
||||
// 输入: "JavaScript, Node.js, 教程" |
|
||||
// 存储: "JavaScript, Node.js, 教程" |
|
||||
// 查询: 支持模糊匹配 |
|
||||
``` |
|
||||
|
|
||||
### SEO优化 |
|
||||
|
|
||||
支持完整的SEO字段: |
|
||||
- `meta_title` - 页面标题 |
|
||||
- `meta_description` - 页面描述 |
|
||||
- `keywords` - 关键词 |
|
||||
- `slug` - URL友好的标识符 |
|
||||
|
|
||||
### 错误处理 |
|
||||
|
|
||||
所有方法都包含适当的错误处理: |
|
||||
```javascript |
|
||||
try { |
|
||||
const article = await ArticleModel.create({ |
|
||||
title: "", // 空标题会抛出错误 |
|
||||
content: "内容" |
|
||||
}) |
|
||||
} catch (error) { |
|
||||
console.error("创建文章失败:", error.message) |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 性能优化 |
|
||||
|
|
||||
- 所有查询都包含适当的索引 |
|
||||
- 支持分页查询 |
|
||||
- 缓存友好的查询结构 |
|
||||
|
|
||||
### 迁移和种子 |
|
||||
|
|
||||
项目包含完整的数据库迁移和种子文件: |
|
||||
- `20250830014825_create_articles_table.mjs` - 创建articles表 |
|
||||
- `20250830020000_add_article_fields.mjs` - 添加额外字段 |
|
||||
- `20250830020000_articles_seed.mjs` - 示例数据 |
|
||||
|
|
||||
### 运行迁移和种子 |
|
||||
|
|
||||
```bash |
|
||||
# 运行迁移 |
|
||||
npx knex migrate:latest |
|
||||
|
|
||||
# 运行种子 |
|
||||
npx knex seed:run |
|
||||
``` |
|
||||
@ -1,194 +0,0 @@ |
|||||
# 数据库模型文档 |
|
||||
|
|
||||
## BookmarkModel |
|
||||
|
|
||||
BookmarkModel 是一个书签管理模型,提供了用户书签的CRUD操作和查询方法,支持URL去重和用户隔离。 |
|
||||
|
|
||||
### 主要特性 |
|
||||
|
|
||||
- ✅ 完整的CRUD操作 |
|
||||
- ✅ 用户隔离的书签管理 |
|
||||
- ✅ URL去重验证 |
|
||||
- ✅ 自动时间戳管理 |
|
||||
- ✅ 外键关联用户表 |
|
||||
|
|
||||
### 数据库字段 |
|
||||
|
|
||||
| 字段名 | 类型 | 说明 | |
|
||||
|--------|------|------| |
|
||||
| id | integer | 主键,自增 | |
|
||||
| user_id | integer | 用户ID(外键,关联users表) | |
|
||||
| title | string(200) | 书签标题(必填,最大长度200) | |
|
||||
| url | string(500) | 书签URL | |
|
||||
| description | text | 书签描述 | |
|
||||
| created_at | timestamp | 创建时间 | |
|
||||
| updated_at | timestamp | 更新时间 | |
|
||||
|
|
||||
### 外键关系 |
|
||||
|
|
||||
- `user_id` 关联 `users.id` |
|
||||
- 删除用户时,相关书签会自动删除(CASCADE) |
|
||||
|
|
||||
### 基本用法 |
|
||||
|
|
||||
```javascript |
|
||||
import { BookmarkModel } from '../models/BookmarkModel.js' |
|
||||
|
|
||||
// 创建书签 |
|
||||
const bookmark = await BookmarkModel.create({ |
|
||||
user_id: 1, |
|
||||
title: "GitHub - 开源代码托管平台", |
|
||||
url: "https://github.com", |
|
||||
description: "全球最大的代码托管平台" |
|
||||
}) |
|
||||
|
|
||||
// 查找用户的所有书签 |
|
||||
const userBookmarks = await BookmarkModel.findAllByUser(1) |
|
||||
|
|
||||
// 根据ID查找书签 |
|
||||
const bookmark = await BookmarkModel.findById(1) |
|
||||
|
|
||||
// 更新书签 |
|
||||
await BookmarkModel.update(1, { |
|
||||
title: "GitHub - 更新后的标题", |
|
||||
description: "更新后的描述" |
|
||||
}) |
|
||||
|
|
||||
// 删除书签 |
|
||||
await BookmarkModel.delete(1) |
|
||||
|
|
||||
// 查找用户特定URL的书签 |
|
||||
const bookmark = await BookmarkModel.findByUserAndUrl(1, "https://github.com") |
|
||||
``` |
|
||||
|
|
||||
### 查询方法 |
|
||||
|
|
||||
#### 基础查询 |
|
||||
- `findAllByUser(userId)` - 查找指定用户的所有书签(按ID降序) |
|
||||
- `findById(id)` - 根据ID查找书签 |
|
||||
- `findByUserAndUrl(userId, url)` - 查找用户特定URL的书签 |
|
||||
|
|
||||
#### 数据操作 |
|
||||
- `create(data)` - 创建新书签 |
|
||||
- `update(id, data)` - 更新书签信息 |
|
||||
- `delete(id)` - 删除书签 |
|
||||
|
|
||||
### 数据验证和约束 |
|
||||
|
|
||||
#### 必填字段 |
|
||||
- `user_id` - 用户ID不能为空 |
|
||||
- `title` - 标题不能为空 |
|
||||
|
|
||||
#### 唯一性约束 |
|
||||
- 同一用户下不能存在相同URL的书签 |
|
||||
- 系统会自动检查并阻止重复URL的创建 |
|
||||
|
|
||||
#### URL处理 |
|
||||
- URL会自动去除首尾空格 |
|
||||
- 支持最大500字符的URL长度 |
|
||||
|
|
||||
### 去重逻辑 |
|
||||
|
|
||||
#### 创建时去重 |
|
||||
```javascript |
|
||||
// 创建书签时会自动检查是否已存在相同URL |
|
||||
const exists = await db("bookmarks").where({ |
|
||||
user_id: userId, |
|
||||
url: url |
|
||||
}).first() |
|
||||
|
|
||||
if (exists) { |
|
||||
throw new Error("该用户下已存在相同 URL 的书签") |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### 更新时去重 |
|
||||
```javascript |
|
||||
// 更新时会检查新URL是否与其他书签冲突(排除自身) |
|
||||
const exists = await db("bookmarks") |
|
||||
.where({ user_id: nextUserId, url: nextUrl }) |
|
||||
.andWhereNot({ id }) |
|
||||
.first() |
|
||||
|
|
||||
if (exists) { |
|
||||
throw new Error("该用户下已存在相同 URL 的书签") |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 时间戳管理 |
|
||||
|
|
||||
系统自动管理以下时间戳: |
|
||||
- `created_at` - 创建时自动设置为当前时间 |
|
||||
- `updated_at` - 每次更新时自动设置为当前时间 |
|
||||
|
|
||||
### 错误处理 |
|
||||
|
|
||||
所有方法都包含适当的错误处理: |
|
||||
```javascript |
|
||||
try { |
|
||||
const bookmark = await BookmarkModel.create({ |
|
||||
user_id: 1, |
|
||||
title: "重复的书签", |
|
||||
url: "https://example.com" // 如果已存在会抛出错误 |
|
||||
}) |
|
||||
} catch (error) { |
|
||||
console.error("创建书签失败:", error.message) |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 性能优化 |
|
||||
|
|
||||
- `user_id` 字段已添加索引,提高查询性能 |
|
||||
- 支持按用户ID快速查询书签列表 |
|
||||
|
|
||||
### 迁移和种子 |
|
||||
|
|
||||
项目包含完整的数据库迁移文件: |
|
||||
- `20250830015422_create_bookmarks_table.mjs` - 创建bookmarks表 |
|
||||
|
|
||||
### 运行迁移 |
|
||||
|
|
||||
```bash |
|
||||
# 运行迁移 |
|
||||
npx knex migrate:latest |
|
||||
``` |
|
||||
|
|
||||
### 使用场景 |
|
||||
|
|
||||
#### 个人书签管理 |
|
||||
```javascript |
|
||||
// 用户登录后查看自己的书签 |
|
||||
const myBookmarks = await BookmarkModel.findAllByUser(currentUserId) |
|
||||
``` |
|
||||
|
|
||||
#### 书签同步 |
|
||||
```javascript |
|
||||
// 支持多设备书签同步 |
|
||||
const bookmarks = await BookmarkModel.findAllByUser(userId) |
|
||||
// 可以导出为JSON或其他格式 |
|
||||
``` |
|
||||
|
|
||||
#### 书签分享 |
|
||||
```javascript |
|
||||
// 可以扩展实现书签分享功能 |
|
||||
// 通过添加 share_status 字段实现 |
|
||||
``` |
|
||||
|
|
||||
### 扩展建议 |
|
||||
|
|
||||
可以考虑添加以下功能: |
|
||||
- 书签分类和标签 |
|
||||
- 书签收藏夹 |
|
||||
- 书签导入/导出 |
|
||||
- 书签搜索功能 |
|
||||
- 书签访问统计 |
|
||||
- 书签分享功能 |
|
||||
- 书签同步功能 |
|
||||
- 书签备份和恢复 |
|
||||
|
|
||||
### 安全注意事项 |
|
||||
|
|
||||
1. **用户隔离**: 确保用户只能访问自己的书签 |
|
||||
2. **URL验证**: 在应用层验证URL的有效性 |
|
||||
3. **输入清理**: 对用户输入进行适当的清理和验证 |
|
||||
4. **权限控制**: 实现适当的访问控制机制 |
|
||||
@ -1,252 +0,0 @@ |
|||||
# 数据库文档总览 |
|
||||
|
|
||||
本文档提供了整个数据库系统的概览,包括所有模型、表结构和关系。 |
|
||||
|
|
||||
## 数据库概览 |
|
||||
|
|
||||
这是一个基于 Koa3 和 Knex.js 构建的现代化 Web 应用数据库系统,使用 SQLite 作为数据库引擎。 |
|
||||
|
|
||||
### 技术栈 |
|
||||
|
|
||||
- **数据库**: SQLite3 |
|
||||
- **ORM**: Knex.js |
|
||||
- **迁移工具**: Knex Migrations |
|
||||
- **种子数据**: Knex Seeds |
|
||||
- **数据库驱动**: sqlite3 |
|
||||
|
|
||||
## 数据模型总览 |
|
||||
|
|
||||
### 1. UserModel - 用户管理 |
|
||||
- **表名**: `users` |
|
||||
- **功能**: 用户账户管理、身份验证、角色控制 |
|
||||
- **主要字段**: id, username, email, password, role, phone, age |
|
||||
- **文档**: [UserModel.md](./UserModel.md) |
|
||||
|
|
||||
### 2. ArticleModel - 文章管理 |
|
||||
- **表名**: `articles` |
|
||||
- **功能**: 文章CRUD、状态管理、SEO优化、标签分类 |
|
||||
- **主要字段**: id, title, content, author, category, tags, status, slug |
|
||||
- **文档**: [ArticleModel.md](./ArticleModel.md) |
|
||||
|
|
||||
### 3. BookmarkModel - 书签管理 |
|
||||
- **表名**: `bookmarks` |
|
||||
- **功能**: 用户书签管理、URL去重、用户隔离 |
|
||||
- **主要字段**: id, user_id, title, url, description |
|
||||
- **文档**: [BookmarkModel.md](./BookmarkModel.md) |
|
||||
|
|
||||
### 4. SiteConfigModel - 网站配置 |
|
||||
- **表名**: `site_config` |
|
||||
- **功能**: 键值对配置存储、系统设置管理 |
|
||||
- **主要字段**: id, key, value |
|
||||
- **文档**: [SiteConfigModel.md](./SiteConfigModel.md) |
|
||||
|
|
||||
## 数据库表结构 |
|
||||
|
|
||||
### 表关系图 |
|
||||
|
|
||||
``` |
|
||||
users (用户表) |
|
||||
├── id (主键) |
|
||||
├── username |
|
||||
├── email |
|
||||
├── password |
|
||||
├── role |
|
||||
├── phone |
|
||||
├── age |
|
||||
├── created_at |
|
||||
└── updated_at |
|
||||
|
|
||||
articles (文章表) |
|
||||
├── id (主键) |
|
||||
├── title |
|
||||
├── content |
|
||||
├── author |
|
||||
├── category |
|
||||
├── tags |
|
||||
├── status |
|
||||
├── slug |
|
||||
├── published_at |
|
||||
├── view_count |
|
||||
├── featured_image |
|
||||
├── excerpt |
|
||||
├── reading_time |
|
||||
├── meta_title |
|
||||
├── meta_description |
|
||||
├── keywords |
|
||||
├── description |
|
||||
├── created_at |
|
||||
└── updated_at |
|
||||
|
|
||||
bookmarks (书签表) |
|
||||
├── id (主键) |
|
||||
├── user_id (外键 -> users.id) |
|
||||
├── title |
|
||||
├── url |
|
||||
├── description |
|
||||
├── created_at |
|
||||
└── updated_at |
|
||||
|
|
||||
site_config (网站配置表) |
|
||||
├── id (主键) |
|
||||
├── key (唯一) |
|
||||
├── value |
|
||||
├── created_at |
|
||||
└── updated_at |
|
||||
``` |
|
||||
|
|
||||
### 外键关系 |
|
||||
|
|
||||
- `bookmarks.user_id` → `users.id` (CASCADE 删除) |
|
||||
- 其他表之间暂无直接外键关系 |
|
||||
|
|
||||
## 数据库迁移文件 |
|
||||
|
|
||||
| 迁移文件 | 描述 | 创建时间 | |
|
||||
|----------|------|----------| |
|
||||
| `20250616065041_create_users_table.mjs` | 创建用户表 | 2025-06-16 | |
|
||||
| `20250621013128_site_config.mjs` | 创建网站配置表 | 2025-06-21 | |
|
||||
| `20250830014825_create_articles_table.mjs` | 创建文章表 | 2025-08-30 | |
|
||||
| `20250830015422_create_bookmarks_table.mjs` | 创建书签表 | 2025-08-30 | |
|
||||
| `20250830020000_add_article_fields.mjs` | 添加文章额外字段 | 2025-08-30 | |
|
||||
|
|
||||
## 种子数据文件 |
|
||||
|
|
||||
| 种子文件 | 描述 | 创建时间 | |
|
||||
|----------|------|----------| |
|
||||
| `20250616071157_users_seed.mjs` | 用户示例数据 | 2025-06-16 | |
|
||||
| `20250621013324_site_config_seed.mjs` | 网站配置示例数据 | 2025-06-21 | |
|
||||
| `20250830020000_articles_seed.mjs` | 文章示例数据 | 2025-08-30 | |
|
||||
|
|
||||
## 快速开始 |
|
||||
|
|
||||
### 1. 安装依赖 |
|
||||
|
|
||||
```bash |
|
||||
npm install |
|
||||
# 或 |
|
||||
bun install |
|
||||
``` |
|
||||
|
|
||||
### 2. 运行数据库迁移 |
|
||||
|
|
||||
```bash |
|
||||
# 运行所有迁移 |
|
||||
npx knex migrate:latest |
|
||||
|
|
||||
# 回滚迁移 |
|
||||
npx knex migrate:rollback |
|
||||
|
|
||||
# 查看迁移状态 |
|
||||
npx knex migrate:status |
|
||||
``` |
|
||||
|
|
||||
### 3. 运行种子数据 |
|
||||
|
|
||||
```bash |
|
||||
# 运行所有种子 |
|
||||
npx knex seed:run |
|
||||
|
|
||||
# 运行特定种子 |
|
||||
npx knex seed:run --specific=20250616071157_users_seed.mjs |
|
||||
``` |
|
||||
|
|
||||
### 4. 数据库连接 |
|
||||
|
|
||||
```bash |
|
||||
# 查看数据库配置 |
|
||||
cat knexfile.mjs |
|
||||
|
|
||||
# 连接数据库 |
|
||||
npx knex --knexfile knexfile.mjs |
|
||||
``` |
|
||||
|
|
||||
## 开发指南 |
|
||||
|
|
||||
### 创建新的迁移文件 |
|
||||
|
|
||||
```bash |
|
||||
npx knex migrate:make create_new_table |
|
||||
``` |
|
||||
|
|
||||
### 创建新的种子文件 |
|
||||
|
|
||||
```bash |
|
||||
npx knex seed:make new_seed_data |
|
||||
``` |
|
||||
|
|
||||
### 创建新的模型 |
|
||||
|
|
||||
1. 在 `src/db/models/` 目录下创建新的模型文件 |
|
||||
2. 在 `src/db/docs/` 目录下创建对应的文档 |
|
||||
3. 更新本文档的模型总览部分 |
|
||||
|
|
||||
## 最佳实践 |
|
||||
|
|
||||
### 1. 模型设计原则 |
|
||||
|
|
||||
- 每个模型对应一个数据库表 |
|
||||
- 使用静态方法提供数据操作接口 |
|
||||
- 实现适当的错误处理和验证 |
|
||||
- 支持软删除和审计字段 |
|
||||
|
|
||||
### 2. 迁移管理 |
|
||||
|
|
||||
- 迁移文件一旦提交到版本控制,不要修改 |
|
||||
- 使用描述性的迁移文件名 |
|
||||
- 在迁移文件中添加适当的注释 |
|
||||
- 测试迁移的回滚功能 |
|
||||
|
|
||||
### 3. 种子数据 |
|
||||
|
|
||||
- 种子数据应该包含测试和开发所需的最小数据集 |
|
||||
- 避免在生产环境中运行种子 |
|
||||
- 种子数据应该是幂等的(可重复运行) |
|
||||
|
|
||||
### 4. 性能优化 |
|
||||
|
|
||||
- 为常用查询字段添加索引 |
|
||||
- 使用批量操作减少数据库查询 |
|
||||
- 实现适当的缓存机制 |
|
||||
- 监控查询性能 |
|
||||
|
|
||||
## 故障排除 |
|
||||
|
|
||||
### 常见问题 |
|
||||
|
|
||||
1. **迁移失败** |
|
||||
- 检查数据库连接配置 |
|
||||
- 确保数据库文件存在且有写入权限 |
|
||||
- 查看迁移文件语法是否正确 |
|
||||
|
|
||||
2. **种子数据失败** |
|
||||
- 检查表结构是否与种子数据匹配 |
|
||||
- 确保外键关系正确 |
|
||||
- 查看是否有唯一性约束冲突 |
|
||||
|
|
||||
3. **模型查询错误** |
|
||||
- 检查表名和字段名是否正确 |
|
||||
- 确保数据库连接正常 |
|
||||
- 查看SQL查询日志 |
|
||||
|
|
||||
### 调试技巧 |
|
||||
|
|
||||
```bash |
|
||||
# 启用SQL查询日志 |
|
||||
DEBUG=knex:query node your-app.js |
|
||||
|
|
||||
# 查看数据库结构 |
|
||||
npx knex --knexfile knexfile.mjs |
|
||||
.tables |
|
||||
.schema users |
|
||||
``` |
|
||||
|
|
||||
## 贡献指南 |
|
||||
|
|
||||
1. 遵循现有的代码风格和命名规范 |
|
||||
2. 为新功能添加适当的测试 |
|
||||
3. 更新相关文档 |
|
||||
4. 提交前运行迁移和种子测试 |
|
||||
|
|
||||
## 许可证 |
|
||||
|
|
||||
本项目采用 MIT 许可证。 |
|
||||
@ -1,246 +0,0 @@ |
|||||
# 数据库模型文档 |
|
||||
|
|
||||
## SiteConfigModel |
|
||||
|
|
||||
SiteConfigModel 是一个网站配置管理模型,提供了灵活的键值对配置存储和管理功能,支持单个配置项和批量配置操作。 |
|
||||
|
|
||||
### 主要特性 |
|
||||
|
|
||||
- ✅ 键值对配置存储 |
|
||||
- ✅ 单个和批量配置操作 |
|
||||
- ✅ 自动时间戳管理 |
|
||||
- ✅ 配置项唯一性保证 |
|
||||
- ✅ 灵活的配置值类型支持 |
|
||||
|
|
||||
### 数据库字段 |
|
||||
|
|
||||
| 字段名 | 类型 | 说明 | |
|
||||
|--------|------|------| |
|
||||
| id | integer | 主键,自增 | |
|
||||
| key | string(100) | 配置项键名(必填,唯一,最大长度100) | |
|
||||
| value | text | 配置项值(必填) | |
|
||||
| created_at | timestamp | 创建时间 | |
|
||||
| updated_at | timestamp | 更新时间 | |
|
||||
|
|
||||
### 基本用法 |
|
||||
|
|
||||
```javascript |
|
||||
import { SiteConfigModel } from '../models/SiteConfigModel.js' |
|
||||
|
|
||||
// 设置单个配置项 |
|
||||
await SiteConfigModel.set("site_name", "我的网站") |
|
||||
await SiteConfigModel.set("site_description", "一个优秀的网站") |
|
||||
await SiteConfigModel.set("maintenance_mode", "false") |
|
||||
|
|
||||
// 获取单个配置项 |
|
||||
const siteName = await SiteConfigModel.get("site_name") |
|
||||
// 返回: "我的网站" |
|
||||
|
|
||||
// 批量获取配置项 |
|
||||
const configs = await SiteConfigModel.getMany([ |
|
||||
"site_name", |
|
||||
"site_description", |
|
||||
"maintenance_mode" |
|
||||
]) |
|
||||
// 返回: { site_name: "我的网站", site_description: "一个优秀的网站", maintenance_mode: "false" } |
|
||||
|
|
||||
// 获取所有配置 |
|
||||
const allConfigs = await SiteConfigModel.getAll() |
|
||||
// 返回所有配置项的键值对对象 |
|
||||
``` |
|
||||
|
|
||||
### 核心方法 |
|
||||
|
|
||||
#### 单个配置操作 |
|
||||
- `get(key)` - 获取指定key的配置值 |
|
||||
- `set(key, value)` - 设置配置项(有则更新,无则插入) |
|
||||
|
|
||||
#### 批量配置操作 |
|
||||
- `getMany(keys)` - 批量获取多个key的配置值 |
|
||||
- `getAll()` - 获取所有配置项 |
|
||||
|
|
||||
### 配置管理策略 |
|
||||
|
|
||||
#### 自动更新机制 |
|
||||
```javascript |
|
||||
// set方法会自动处理配置项的创建和更新 |
|
||||
static async set(key, value) { |
|
||||
const exists = await db("site_config").where({ key }).first() |
|
||||
if (exists) { |
|
||||
// 如果配置项存在,则更新 |
|
||||
await db("site_config").where({ key }).update({ |
|
||||
value, |
|
||||
updated_at: db.fn.now() |
|
||||
}) |
|
||||
} else { |
|
||||
// 如果配置项不存在,则创建 |
|
||||
await db("site_config").insert({ key, value }) |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### 批量获取优化 |
|
||||
```javascript |
|
||||
// 批量获取时使用 whereIn 优化查询性能 |
|
||||
static async getMany(keys) { |
|
||||
const rows = await db("site_config").whereIn("key", keys) |
|
||||
const result = {} |
|
||||
rows.forEach(row => { |
|
||||
result[row.key] = row.value |
|
||||
}) |
|
||||
return result |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 配置值类型支持 |
|
||||
|
|
||||
支持多种配置值类型: |
|
||||
|
|
||||
#### 字符串配置 |
|
||||
```javascript |
|
||||
await SiteConfigModel.set("site_name", "我的网站") |
|
||||
await SiteConfigModel.set("contact_email", "admin@example.com") |
|
||||
``` |
|
||||
|
|
||||
#### 布尔值配置 |
|
||||
```javascript |
|
||||
await SiteConfigModel.set("maintenance_mode", "false") |
|
||||
await SiteConfigModel.set("debug_mode", "true") |
|
||||
``` |
|
||||
|
|
||||
#### 数字配置 |
|
||||
```javascript |
|
||||
await SiteConfigModel.set("max_upload_size", "10485760") // 10MB |
|
||||
await SiteConfigModel.set("session_timeout", "3600") // 1小时 |
|
||||
``` |
|
||||
|
|
||||
#### JSON配置 |
|
||||
```javascript |
|
||||
await SiteConfigModel.set("social_links", JSON.stringify({ |
|
||||
twitter: "https://twitter.com/example", |
|
||||
facebook: "https://facebook.com/example" |
|
||||
})) |
|
||||
``` |
|
||||
|
|
||||
### 使用场景 |
|
||||
|
|
||||
#### 网站基本信息配置 |
|
||||
```javascript |
|
||||
// 设置网站基本信息 |
|
||||
await SiteConfigModel.set("site_name", "我的博客") |
|
||||
await SiteConfigModel.set("site_description", "分享技术和生活") |
|
||||
await SiteConfigModel.set("site_keywords", "技术,博客,编程") |
|
||||
await SiteConfigModel.set("site_author", "张三") |
|
||||
``` |
|
||||
|
|
||||
#### 功能开关配置 |
|
||||
```javascript |
|
||||
// 功能开关 |
|
||||
await SiteConfigModel.set("enable_comments", "true") |
|
||||
await SiteConfigModel.set("enable_registration", "false") |
|
||||
await SiteConfigModel.set("enable_analytics", "true") |
|
||||
``` |
|
||||
|
|
||||
#### 系统配置 |
|
||||
```javascript |
|
||||
// 系统配置 |
|
||||
await SiteConfigModel.set("max_login_attempts", "5") |
|
||||
await SiteConfigModel.set("password_min_length", "8") |
|
||||
await SiteConfigModel.set("session_timeout", "3600") |
|
||||
``` |
|
||||
|
|
||||
#### 第三方服务配置 |
|
||||
```javascript |
|
||||
// 第三方服务配置 |
|
||||
await SiteConfigModel.set("google_analytics_id", "GA-XXXXXXXXX") |
|
||||
await SiteConfigModel.set("recaptcha_site_key", "6LcXXXXXXXX") |
|
||||
await SiteConfigModel.set("smtp_host", "smtp.gmail.com") |
|
||||
``` |
|
||||
|
|
||||
### 配置获取和缓存 |
|
||||
|
|
||||
#### 基础获取 |
|
||||
```javascript |
|
||||
// 获取网站名称 |
|
||||
const siteName = await SiteConfigModel.get("site_name") || "默认网站名称" |
|
||||
|
|
||||
// 获取维护模式状态 |
|
||||
const isMaintenance = await SiteConfigModel.get("maintenance_mode") === "true" |
|
||||
``` |
|
||||
|
|
||||
#### 批量获取优化 |
|
||||
```javascript |
|
||||
// 一次性获取多个配置项,减少数据库查询 |
|
||||
const configs = await SiteConfigModel.getMany([ |
|
||||
"site_name", |
|
||||
"site_description", |
|
||||
"maintenance_mode" |
|
||||
]) |
|
||||
|
|
||||
// 使用配置 |
|
||||
if (configs.maintenance_mode === "true") { |
|
||||
console.log("网站维护中") |
|
||||
} else { |
|
||||
console.log(`欢迎访问 ${configs.site_name}`) |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 错误处理 |
|
||||
|
|
||||
所有方法都包含适当的错误处理: |
|
||||
```javascript |
|
||||
try { |
|
||||
const siteName = await SiteConfigModel.get("site_name") |
|
||||
if (!siteName) { |
|
||||
console.log("网站名称未配置,使用默认值") |
|
||||
return "默认网站名称" |
|
||||
} |
|
||||
return siteName |
|
||||
} catch (error) { |
|
||||
console.error("获取配置失败:", error.message) |
|
||||
return "默认网站名称" |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 性能优化 |
|
||||
|
|
||||
- `key` 字段已添加唯一索引,提高查询性能 |
|
||||
- 支持批量操作,减少数据库查询次数 |
|
||||
- 建议在应用层实现配置缓存机制 |
|
||||
|
|
||||
### 迁移和种子 |
|
||||
|
|
||||
项目包含完整的数据库迁移和种子文件: |
|
||||
- `20250621013128_site_config.mjs` - 创建site_config表 |
|
||||
- `20250621013324_site_config_seed.mjs` - 示例配置数据 |
|
||||
|
|
||||
### 运行迁移和种子 |
|
||||
|
|
||||
```bash |
|
||||
# 运行迁移 |
|
||||
npx knex migrate:latest |
|
||||
|
|
||||
# 运行种子 |
|
||||
npx knex seed:run |
|
||||
``` |
|
||||
|
|
||||
### 扩展建议 |
|
||||
|
|
||||
可以考虑添加以下功能: |
|
||||
- 配置项分类管理 |
|
||||
- 配置项验证规则 |
|
||||
- 配置变更历史记录 |
|
||||
- 配置导入/导出功能 |
|
||||
- 配置项权限控制 |
|
||||
- 配置项版本管理 |
|
||||
- 配置项依赖关系 |
|
||||
- 配置项加密存储 |
|
||||
|
|
||||
### 最佳实践 |
|
||||
|
|
||||
1. **配置项命名**: 使用清晰的命名规范,如 `feature_name` 或 `service_config` |
|
||||
2. **配置值类型**: 统一配置值的类型,如布尔值统一使用字符串 "true"/"false" |
|
||||
3. **配置分组**: 使用前缀对配置项进行分组,如 `email_`, `social_`, `system_` |
|
||||
4. **默认值处理**: 在应用层为配置项提供合理的默认值 |
|
||||
5. **配置验证**: 在设置配置项时验证值的有效性 |
|
||||
6. **配置缓存**: 实现配置缓存机制,减少数据库查询 |
|
||||
@ -1,158 +0,0 @@ |
|||||
# 数据库模型文档 |
|
||||
|
|
||||
## UserModel |
|
||||
|
|
||||
UserModel 是一个用户管理模型,提供了基本的用户CRUD操作和查询方法。 |
|
||||
|
|
||||
### 主要特性 |
|
||||
|
|
||||
- ✅ 完整的CRUD操作 |
|
||||
- ✅ 用户身份验证支持 |
|
||||
- ✅ 用户名和邮箱唯一性验证 |
|
||||
- ✅ 角色管理 |
|
||||
- ✅ 时间戳自动管理 |
|
||||
|
|
||||
### 数据库字段 |
|
||||
|
|
||||
| 字段名 | 类型 | 说明 | |
|
||||
|--------|------|------| |
|
||||
| id | integer | 主键,自增 | |
|
||||
| username | string(100) | 用户名(必填,最大长度100) | |
|
||||
| email | string(100) | 邮箱(唯一) | |
|
||||
| password | string(100) | 密码(必填) | |
|
||||
| role | string(100) | 用户角色(必填) | |
|
||||
| phone | string(100) | 电话号码 | |
|
||||
| age | integer | 年龄(无符号整数) | |
|
||||
| created_at | timestamp | 创建时间 | |
|
||||
| updated_at | timestamp | 更新时间 | |
|
||||
|
|
||||
### 基本用法 |
|
||||
|
|
||||
```javascript |
|
||||
import { UserModel } from '../models/UserModel.js' |
|
||||
|
|
||||
// 创建用户 |
|
||||
const user = await UserModel.create({ |
|
||||
username: "zhangsan", |
|
||||
email: "zhangsan@example.com", |
|
||||
password: "hashedPassword", |
|
||||
role: "user", |
|
||||
phone: "13800138000", |
|
||||
age: 25 |
|
||||
}) |
|
||||
|
|
||||
// 查找所有用户 |
|
||||
const allUsers = await UserModel.findAll() |
|
||||
|
|
||||
// 根据ID查找用户 |
|
||||
const user = await UserModel.findById(1) |
|
||||
|
|
||||
// 根据用户名查找用户 |
|
||||
const user = await UserModel.findByUsername("zhangsan") |
|
||||
|
|
||||
// 根据邮箱查找用户 |
|
||||
const user = await UserModel.findByEmail("zhangsan@example.com") |
|
||||
|
|
||||
// 更新用户信息 |
|
||||
await UserModel.update(1, { |
|
||||
phone: "13900139000", |
|
||||
age: 26 |
|
||||
}) |
|
||||
|
|
||||
// 删除用户 |
|
||||
await UserModel.delete(1) |
|
||||
``` |
|
||||
|
|
||||
### 查询方法 |
|
||||
|
|
||||
#### 基础查询 |
|
||||
- `findAll()` - 查找所有用户 |
|
||||
- `findById(id)` - 根据ID查找用户 |
|
||||
- `findByUsername(username)` - 根据用户名查找用户 |
|
||||
- `findByEmail(email)` - 根据邮箱查找用户 |
|
||||
|
|
||||
#### 数据操作 |
|
||||
- `create(data)` - 创建新用户 |
|
||||
- `update(id, data)` - 更新用户信息 |
|
||||
- `delete(id)` - 删除用户 |
|
||||
|
|
||||
### 数据验证 |
|
||||
|
|
||||
#### 必填字段 |
|
||||
- `username` - 用户名不能为空 |
|
||||
- `password` - 密码不能为空 |
|
||||
- `role` - 角色不能为空 |
|
||||
|
|
||||
#### 唯一性约束 |
|
||||
- `email` - 邮箱必须唯一 |
|
||||
- `username` - 建议在应用层实现唯一性验证 |
|
||||
|
|
||||
### 时间戳管理 |
|
||||
|
|
||||
系统自动管理以下时间戳: |
|
||||
- `created_at` - 创建时自动设置为当前时间 |
|
||||
- `updated_at` - 每次更新时自动设置为当前时间 |
|
||||
|
|
||||
### 角色管理 |
|
||||
|
|
||||
支持用户角色字段,可用于权限控制: |
|
||||
```javascript |
|
||||
// 常见角色示例 |
|
||||
const roles = { |
|
||||
admin: "管理员", |
|
||||
user: "普通用户", |
|
||||
moderator: "版主" |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 错误处理 |
|
||||
|
|
||||
所有方法都包含适当的错误处理: |
|
||||
```javascript |
|
||||
try { |
|
||||
const user = await UserModel.create({ |
|
||||
username: "", // 空用户名会抛出错误 |
|
||||
password: "password" |
|
||||
}) |
|
||||
} catch (error) { |
|
||||
console.error("创建用户失败:", error.message) |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 性能优化 |
|
||||
|
|
||||
- 建议为 `username` 和 `email` 字段添加索引 |
|
||||
- 支持分页查询(需要扩展实现) |
|
||||
|
|
||||
### 迁移和种子 |
|
||||
|
|
||||
项目包含完整的数据库迁移和种子文件: |
|
||||
- `20250616065041_create_users_table.mjs` - 创建users表 |
|
||||
- `20250616071157_users_seed.mjs` - 示例用户数据 |
|
||||
|
|
||||
### 运行迁移和种子 |
|
||||
|
|
||||
```bash |
|
||||
# 运行迁移 |
|
||||
npx knex migrate:latest |
|
||||
|
|
||||
# 运行种子 |
|
||||
npx knex seed:run |
|
||||
``` |
|
||||
|
|
||||
### 安全注意事项 |
|
||||
|
|
||||
1. **密码安全**: 在创建用户前,确保密码已经过哈希处理 |
|
||||
2. **输入验证**: 在应用层验证用户输入数据的有效性 |
|
||||
3. **权限控制**: 根据用户角色实现适当的访问控制 |
|
||||
4. **SQL注入防护**: 使用Knex.js的参数化查询防止SQL注入 |
|
||||
|
|
||||
### 扩展建议 |
|
||||
|
|
||||
可以考虑添加以下功能: |
|
||||
- 用户状态管理(激活/禁用) |
|
||||
- 密码重置功能 |
|
||||
- 用户头像管理 |
|
||||
- 用户偏好设置 |
|
||||
- 登录历史记录 |
|
||||
- 用户组管理 |
|
||||
@ -1,26 +0,0 @@ |
|||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const up = async knex => { |
|
||||
return knex.schema.createTable("articles", table => { |
|
||||
table.increments("id").primary() |
|
||||
table.string("title").notNullable() |
|
||||
table.string("content").notNullable() |
|
||||
table.string("author") |
|
||||
table.string("category") |
|
||||
table.string("tags") |
|
||||
table.string("keywords") |
|
||||
table.string("description") |
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const down = async knex => { |
|
||||
return knex.schema.dropTable("articles") |
|
||||
} |
|
||||
@ -1,25 +0,0 @@ |
|||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const up = async knex => { |
|
||||
return knex.schema.createTable("bookmarks", function (table) { |
|
||||
table.increments("id").primary() |
|
||||
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE") |
|
||||
table.string("title", 200).notNullable() |
|
||||
table.string("url", 500) |
|
||||
table.text("description") |
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now()) |
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
|
||||
|
|
||||
table.index(["user_id"]) // 常用查询索引
|
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const down = async knex => { |
|
||||
return knex.schema.dropTable("bookmarks") |
|
||||
} |
|
||||
@ -1,60 +0,0 @@ |
|||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const up = async knex => { |
|
||||
return knex.schema.alterTable("articles", table => { |
|
||||
// 添加浏览量字段
|
|
||||
table.integer("view_count").defaultTo(0) |
|
||||
|
|
||||
// 添加发布时间字段
|
|
||||
table.timestamp("published_at") |
|
||||
|
|
||||
// 添加状态字段 (draft, published, archived)
|
|
||||
table.string("status").defaultTo("draft") |
|
||||
|
|
||||
// 添加特色图片字段
|
|
||||
table.string("featured_image") |
|
||||
|
|
||||
// 添加摘要字段
|
|
||||
table.text("excerpt") |
|
||||
|
|
||||
// 添加阅读时间估算字段(分钟)
|
|
||||
table.integer("reading_time") |
|
||||
|
|
||||
// 添加SEO相关字段
|
|
||||
table.string("meta_title") |
|
||||
table.text("meta_description") |
|
||||
table.string("slug").unique() |
|
||||
|
|
||||
// 添加索引以提高查询性能
|
|
||||
table.index(["status", "published_at"]) |
|
||||
table.index(["category"]) |
|
||||
table.index(["author"]) |
|
||||
table.index(["created_at"]) |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const down = async knex => { |
|
||||
return knex.schema.alterTable("articles", table => { |
|
||||
table.dropColumn("view_count") |
|
||||
table.dropColumn("published_at") |
|
||||
table.dropColumn("status") |
|
||||
table.dropColumn("featured_image") |
|
||||
table.dropColumn("excerpt") |
|
||||
table.dropColumn("reading_time") |
|
||||
table.dropColumn("meta_title") |
|
||||
table.dropColumn("meta_description") |
|
||||
table.dropColumn("slug") |
|
||||
|
|
||||
// 删除索引
|
|
||||
table.dropIndex(["status", "published_at"]) |
|
||||
table.dropIndex(["category"]) |
|
||||
table.dropIndex(["author"]) |
|
||||
table.dropIndex(["created_at"]) |
|
||||
}) |
|
||||
} |
|
||||
@ -1,25 +0,0 @@ |
|||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const up = async knex => { |
|
||||
return knex.schema.alterTable("users", function (table) { |
|
||||
table.string("name", 100) // 昵称
|
|
||||
table.text("bio") // 个人简介
|
|
||||
table.string("avatar", 500) // 头像URL
|
|
||||
table.string("status", 20).defaultTo("active") // 用户状态
|
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const down = async knex => { |
|
||||
return knex.schema.alterTable("users", function (table) { |
|
||||
table.dropColumn("name") |
|
||||
table.dropColumn("bio") |
|
||||
table.dropColumn("avatar") |
|
||||
table.dropColumn("status") |
|
||||
}) |
|
||||
} |
|
||||
@ -1,31 +0,0 @@ |
|||||
/** |
|
||||
* 联系信息表迁移文件 |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const up = function(knex) { |
|
||||
return knex.schema.createTable('contacts', function (table) { |
|
||||
table.increments('id').primary(); |
|
||||
table.string('name', 100).notNullable().comment('联系人姓名'); |
|
||||
table.string('email', 255).notNullable().comment('邮箱地址'); |
|
||||
table.string('subject', 255).notNullable().comment('主题'); |
|
||||
table.text('message').notNullable().comment('留言内容'); |
|
||||
table.string('ip_address', 45).nullable().comment('IP地址'); |
|
||||
table.text('user_agent').nullable().comment('浏览器信息'); |
|
||||
table.string('status', 20).defaultTo('unread').comment('处理状态: unread, read, replied'); |
|
||||
table.timestamps(true, true); // created_at, updated_at
|
|
||||
|
|
||||
// 添加索引
|
|
||||
table.index('status', 'idx_contacts_status'); |
|
||||
table.index('created_at', 'idx_contacts_created_at'); |
|
||||
table.index('email', 'idx_contacts_email'); |
|
||||
}); |
|
||||
}; |
|
||||
|
|
||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const down = function(knex) { |
|
||||
return knex.schema.dropTable('contacts'); |
|
||||
}; |
|
||||
@ -1,146 +0,0 @@ |
|||||
/** |
|
||||
* 数据库性能优化索引迁移 |
|
||||
* 添加必要的复合索引以提升查询性能 |
|
||||
*/ |
|
||||
|
|
||||
export const up = async (knex) => { |
|
||||
console.log('开始添加性能优化索引...') |
|
||||
|
|
||||
// 用户表索引优化
|
|
||||
await knex.schema.alterTable("users", (table) => { |
|
||||
// 单字段索引
|
|
||||
table.index(["email"], "idx_users_email") |
|
||||
table.index(["username"], "idx_users_username") |
|
||||
table.index(["status"], "idx_users_status") |
|
||||
table.index(["role"], "idx_users_role") |
|
||||
table.index(["created_at"], "idx_users_created_at") |
|
||||
|
|
||||
// 复合索引
|
|
||||
table.index(["status", "created_at"], "idx_users_status_created") |
|
||||
table.index(["role", "status"], "idx_users_role_status") |
|
||||
}) |
|
||||
console.log('✓ 用户表索引添加完成') |
|
||||
|
|
||||
// 文章表索引优化
|
|
||||
await knex.schema.alterTable("articles", (table) => { |
|
||||
// 单字段索引
|
|
||||
table.index(["author"], "idx_articles_author") |
|
||||
table.index(["category"], "idx_articles_category") |
|
||||
table.index(["status"], "idx_articles_status") |
|
||||
table.index(["slug"], "idx_articles_slug") |
|
||||
table.index(["published_at"], "idx_articles_published_at") |
|
||||
table.index(["view_count"], "idx_articles_view_count") |
|
||||
table.index(["created_at"], "idx_articles_created_at") |
|
||||
table.index(["updated_at"], "idx_articles_updated_at") |
|
||||
|
|
||||
// 复合索引 - 提升常用查询性能
|
|
||||
table.index(["status", "published_at"], "idx_articles_status_published") |
|
||||
table.index(["author", "status"], "idx_articles_author_status") |
|
||||
table.index(["category", "status"], "idx_articles_category_status") |
|
||||
table.index(["status", "view_count"], "idx_articles_status_views") |
|
||||
table.index(["author", "created_at"], "idx_articles_author_created") |
|
||||
table.index(["category", "published_at"], "idx_articles_category_published") |
|
||||
|
|
||||
// 全文搜索相关索引(SQLite不支持全文索引,但可以优化LIKE查询)
|
|
||||
// 注意:SQLite的LIKE查询在列开头匹配时可以使用索引
|
|
||||
}) |
|
||||
console.log('✓ 文章表索引添加完成') |
|
||||
|
|
||||
// 书签表索引优化
|
|
||||
await knex.schema.alterTable("bookmarks", (table) => { |
|
||||
// 单字段索引
|
|
||||
table.index(["user_id"], "idx_bookmarks_user_id") |
|
||||
table.index(["url"], "idx_bookmarks_url") |
|
||||
table.index(["created_at"], "idx_bookmarks_created_at") |
|
||||
|
|
||||
// 复合索引
|
|
||||
table.index(["user_id", "created_at"], "idx_bookmarks_user_created") |
|
||||
table.index(["user_id", "url"], "idx_bookmarks_user_url") // 用于查重
|
|
||||
}) |
|
||||
console.log('✓ 书签表索引添加完成') |
|
||||
|
|
||||
// 联系人表索引优化
|
|
||||
await knex.schema.alterTable("contacts", (table) => { |
|
||||
// 单字段索引
|
|
||||
table.index(["email"], "idx_contacts_email") |
|
||||
table.index(["status"], "idx_contacts_status") |
|
||||
table.index(["created_at"], "idx_contacts_created_at") |
|
||||
|
|
||||
// 复合索引
|
|
||||
table.index(["status", "created_at"], "idx_contacts_status_created") |
|
||||
table.index(["email", "created_at"], "idx_contacts_email_created") |
|
||||
}) |
|
||||
console.log('✓ 联系人表索引添加完成') |
|
||||
|
|
||||
// 站点配置表索引优化
|
|
||||
await knex.schema.alterTable("site_config", (table) => { |
|
||||
// key字段应该已经有唯一索引,这里添加其他有用的索引
|
|
||||
table.index(["updated_at"], "idx_site_config_updated_at") |
|
||||
}) |
|
||||
console.log('✓ 站点配置表索引添加完成') |
|
||||
|
|
||||
console.log('所有性能优化索引添加完成!') |
|
||||
} |
|
||||
|
|
||||
export const down = async (knex) => { |
|
||||
console.log('开始移除性能优化索引...') |
|
||||
|
|
||||
// 用户表索引移除
|
|
||||
await knex.schema.alterTable("users", (table) => { |
|
||||
table.dropIndex(["email"], "idx_users_email") |
|
||||
table.dropIndex(["username"], "idx_users_username") |
|
||||
table.dropIndex(["status"], "idx_users_status") |
|
||||
table.dropIndex(["role"], "idx_users_role") |
|
||||
table.dropIndex(["created_at"], "idx_users_created_at") |
|
||||
table.dropIndex(["status", "created_at"], "idx_users_status_created") |
|
||||
table.dropIndex(["role", "status"], "idx_users_role_status") |
|
||||
}) |
|
||||
console.log('✓ 用户表索引移除完成') |
|
||||
|
|
||||
// 文章表索引移除
|
|
||||
await knex.schema.alterTable("articles", (table) => { |
|
||||
table.dropIndex(["author"], "idx_articles_author") |
|
||||
table.dropIndex(["category"], "idx_articles_category") |
|
||||
table.dropIndex(["status"], "idx_articles_status") |
|
||||
table.dropIndex(["slug"], "idx_articles_slug") |
|
||||
table.dropIndex(["published_at"], "idx_articles_published_at") |
|
||||
table.dropIndex(["view_count"], "idx_articles_view_count") |
|
||||
table.dropIndex(["created_at"], "idx_articles_created_at") |
|
||||
table.dropIndex(["updated_at"], "idx_articles_updated_at") |
|
||||
table.dropIndex(["status", "published_at"], "idx_articles_status_published") |
|
||||
table.dropIndex(["author", "status"], "idx_articles_author_status") |
|
||||
table.dropIndex(["category", "status"], "idx_articles_category_status") |
|
||||
table.dropIndex(["status", "view_count"], "idx_articles_status_views") |
|
||||
table.dropIndex(["author", "created_at"], "idx_articles_author_created") |
|
||||
table.dropIndex(["category", "published_at"], "idx_articles_category_published") |
|
||||
}) |
|
||||
console.log('✓ 文章表索引移除完成') |
|
||||
|
|
||||
// 书签表索引移除
|
|
||||
await knex.schema.alterTable("bookmarks", (table) => { |
|
||||
table.dropIndex(["user_id"], "idx_bookmarks_user_id") |
|
||||
table.dropIndex(["url"], "idx_bookmarks_url") |
|
||||
table.dropIndex(["created_at"], "idx_bookmarks_created_at") |
|
||||
table.dropIndex(["user_id", "created_at"], "idx_bookmarks_user_created") |
|
||||
table.dropIndex(["user_id", "url"], "idx_bookmarks_user_url") |
|
||||
}) |
|
||||
console.log('✓ 书签表索引移除完成') |
|
||||
|
|
||||
// 联系人表索引移除
|
|
||||
await knex.schema.alterTable("contacts", (table) => { |
|
||||
table.dropIndex(["email"], "idx_contacts_email") |
|
||||
table.dropIndex(["status"], "idx_contacts_status") |
|
||||
table.dropIndex(["created_at"], "idx_contacts_created_at") |
|
||||
table.dropIndex(["status", "created_at"], "idx_contacts_status_created") |
|
||||
table.dropIndex(["email", "created_at"], "idx_contacts_email_created") |
|
||||
}) |
|
||||
console.log('✓ 联系人表索引移除完成') |
|
||||
|
|
||||
// 站点配置表索引移除
|
|
||||
await knex.schema.alterTable("site_config", (table) => { |
|
||||
table.dropIndex(["updated_at"], "idx_site_config_updated_at") |
|
||||
}) |
|
||||
console.log('✓ 站点配置表索引移除完成') |
|
||||
|
|
||||
console.log('所有性能优化索引移除完成!') |
|
||||
} |
|
||||
@ -1,548 +0,0 @@ |
|||||
import BaseModel, { handleDatabaseError } from "./BaseModel.js" |
|
||||
import db from "../index.js" |
|
||||
|
|
||||
class ArticleModel extends BaseModel { |
|
||||
static get tableName() { |
|
||||
return "articles" |
|
||||
} |
|
||||
|
|
||||
static get searchableFields() { |
|
||||
return ["title", "content", "tags", "keywords", "description", "excerpt"] |
|
||||
} |
|
||||
|
|
||||
static get filterableFields() { |
|
||||
return ["author", "category", "status"] |
|
||||
} |
|
||||
|
|
||||
static get defaultOrderBy() { |
|
||||
return "created_at" |
|
||||
} |
|
||||
// ==================== 新增关联查询方法 ====================
|
|
||||
|
|
||||
/** |
|
||||
* 获取作者相关文章(包含作者信息) |
|
||||
*/ |
|
||||
static async findByAuthorWithProfile(author) { |
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['articles.author', 'users.username'], |
|
||||
select: [ |
|
||||
'articles.*', |
|
||||
'users.name as author_name', |
|
||||
'users.avatar as author_avatar', |
|
||||
'users.bio as author_bio' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{ 'articles.author': author, 'articles.status': 'published' }, |
|
||||
relations, |
|
||||
{ orderBy: 'articles.published_at', order: 'desc' } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取最受欢迎的文章(包含作者信息) |
|
||||
*/ |
|
||||
static async getPopularArticlesWithAuthor(limit = 10) { |
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['articles.author', 'users.username'], |
|
||||
select: [ |
|
||||
'articles.*', |
|
||||
'users.name as author_name', |
|
||||
'users.avatar as author_avatar' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{ 'articles.status': 'published' }, |
|
||||
relations, |
|
||||
{ orderBy: 'articles.view_count', order: 'desc', limit } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取最新文章(包含作者信息) |
|
||||
*/ |
|
||||
static async getRecentArticlesWithAuthor(limit = 10) { |
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['articles.author', 'users.username'], |
|
||||
select: [ |
|
||||
'articles.*', |
|
||||
'users.name as author_name', |
|
||||
'users.avatar as author_avatar' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{ 'articles.status': 'published' }, |
|
||||
relations, |
|
||||
{ orderBy: 'articles.published_at', order: 'desc', limit } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取精选文章(包含作者信息) |
|
||||
*/ |
|
||||
static async getFeaturedArticlesWithAuthor(limit = 5) { |
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['articles.author', 'users.username'], |
|
||||
select: [ |
|
||||
'articles.*', |
|
||||
'users.name as author_name', |
|
||||
'users.avatar as author_avatar' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{ |
|
||||
'articles.status': 'published', |
|
||||
'articles.featured_image': db.raw('NOT NULL') |
|
||||
}, |
|
||||
relations, |
|
||||
{ orderBy: 'articles.published_at', order: 'desc', limit } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 按分类获取文章(包含作者信息) |
|
||||
*/ |
|
||||
static async findByCategoryWithAuthor(category, limit = 20) { |
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['articles.author', 'users.username'], |
|
||||
select: [ |
|
||||
'articles.*', |
|
||||
'users.name as author_name', |
|
||||
'users.avatar as author_avatar' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{ |
|
||||
'articles.category': category, |
|
||||
'articles.status': 'published' |
|
||||
}, |
|
||||
relations, |
|
||||
{ orderBy: 'articles.published_at', order: 'desc', limit } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 搜索文章(包含作者信息) |
|
||||
*/ |
|
||||
static async searchWithAuthor(keyword, limit = 20) { |
|
||||
try { |
|
||||
return await db(this.tableName) |
|
||||
.leftJoin('users', 'articles.author', 'users.username') |
|
||||
.select( |
|
||||
'articles.*', |
|
||||
'users.name as author_name', |
|
||||
'users.avatar as author_avatar' |
|
||||
) |
|
||||
.where('articles.status', 'published') |
|
||||
.where(function () { |
|
||||
this.where('articles.title', 'like', `%${keyword}%`) |
|
||||
.orWhere('articles.content', 'like', `%${keyword}%`) |
|
||||
.orWhere('articles.keywords', 'like', `%${keyword}%`) |
|
||||
.orWhere('articles.description', 'like', `%${keyword}%`) |
|
||||
.orWhere('articles.excerpt', 'like', `%${keyword}%`) |
|
||||
}) |
|
||||
.orderBy('articles.published_at', 'desc') |
|
||||
.limit(limit) |
|
||||
} catch (error) { |
|
||||
throw handleDatabaseError(error, `搜索文章`) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// ==================== 原有方法保持不变 ====================
|
|
||||
return db("articles").orderBy("created_at", "desc") |
|
||||
} |
|
||||
|
|
||||
static async findPublished(offset, limit) { |
|
||||
let query = db("articles") |
|
||||
.where("status", "published") |
|
||||
.whereNotNull("published_at") |
|
||||
.orderBy("published_at", "desc") |
|
||||
if (typeof offset === "number") { |
|
||||
query = query.offset(offset) |
|
||||
} |
|
||||
if (typeof limit === "number") { |
|
||||
query = query.limit(limit) |
|
||||
} |
|
||||
return query |
|
||||
} |
|
||||
|
|
||||
static async findDrafts() { |
|
||||
return db("articles").where("status", "draft").orderBy("updated_at", "desc") |
|
||||
} |
|
||||
|
|
||||
static async findById(id) { |
|
||||
return db("articles").where("id", id).first() |
|
||||
} |
|
||||
|
|
||||
static async findBySlug(slug) { |
|
||||
return db("articles").where("slug", slug).first() |
|
||||
} |
|
||||
|
|
||||
static async findByAuthor(author) { |
|
||||
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") |
|
||||
} |
|
||||
|
|
||||
static async findByTags(tags) { |
|
||||
// 支持多个标签搜索,标签以逗号分隔
|
|
||||
const tagArray = tags.split(",").map(tag => tag.trim()) |
|
||||
return db("articles") |
|
||||
.where("status", "published") |
|
||||
.whereRaw("tags LIKE ?", [`%${tagArray[0]}%`]) |
|
||||
.orderBy("published_at", "desc") |
|
||||
} |
|
||||
|
|
||||
static async searchByKeyword(keyword) { |
|
||||
return db("articles") |
|
||||
.where("status", "published") |
|
||||
.where(function () { |
|
||||
this.where("title", "like", `%${keyword}%`) |
|
||||
.orWhere("content", "like", `%${keyword}%`) |
|
||||
.orWhere("keywords", "like", `%${keyword}%`) |
|
||||
.orWhere("description", "like", `%${keyword}%`) |
|
||||
.orWhere("excerpt", "like", `%${keyword}%`) |
|
||||
}) |
|
||||
.orderBy("published_at", "desc") |
|
||||
} |
|
||||
|
|
||||
static async create(data) { |
|
||||
// 验证必填字段
|
|
||||
if (!data.title || !data.content) { |
|
||||
throw new Error("标题和内容为必填字段") |
|
||||
} |
|
||||
|
|
||||
// 处理标签,确保格式一致
|
|
||||
let tags = data.tags |
|
||||
if (tags && typeof tags === "string") { |
|
||||
tags = tags |
|
||||
.split(",") |
|
||||
.map(tag => tag.trim()) |
|
||||
.filter(tag => tag) |
|
||||
.join(", ") |
|
||||
} |
|
||||
|
|
||||
// 生成slug(如果未提供)
|
|
||||
let slug = data.slug |
|
||||
if (!slug) { |
|
||||
slug = this.generateSlug(data.title) |
|
||||
} |
|
||||
|
|
||||
// 计算阅读时间(如果未提供)
|
|
||||
let readingTime = data.reading_time |
|
||||
if (!readingTime) { |
|
||||
readingTime = this.calculateReadingTime(data.content) |
|
||||
} |
|
||||
|
|
||||
// 生成摘要(如果未提供)
|
|
||||
let excerpt = data.excerpt |
|
||||
if (!excerpt && data.content) { |
|
||||
excerpt = this.generateExcerpt(data.content) |
|
||||
} |
|
||||
|
|
||||
// 只插入数据库表中存在的字段
|
|
||||
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 Array.isArray(result) ? result[0] : result // 确保返回单个对象
|
|
||||
} |
|
||||
|
|
||||
static async update(id, data) { |
|
||||
const current = await db("articles").where("id", id).first() |
|
||||
if (!current) { |
|
||||
throw new Error("文章不存在") |
|
||||
} |
|
||||
|
|
||||
// 处理标签,确保格式一致
|
|
||||
let tags = data.tags |
|
||||
if (tags && typeof tags === "string") { |
|
||||
tags = tags |
|
||||
.split(",") |
|
||||
.map(tag => tag.trim()) |
|
||||
.filter(tag => tag) |
|
||||
.join(", ") |
|
||||
} |
|
||||
|
|
||||
// 生成slug(如果标题改变且未提供slug)
|
|
||||
let slug = data.slug |
|
||||
if (data.title && data.title !== current.title && !slug) { |
|
||||
slug = this.generateSlug(data.title) |
|
||||
} |
|
||||
|
|
||||
// 计算阅读时间(如果内容改变且未提供)
|
|
||||
let readingTime = data.reading_time |
|
||||
if (data.content && data.content !== current.content && !readingTime) { |
|
||||
readingTime = this.calculateReadingTime(data.content) |
|
||||
} |
|
||||
|
|
||||
// 生成摘要(如果内容改变且未提供)
|
|
||||
let excerpt = data.excerpt |
|
||||
if (data.content && data.content !== current.content && !excerpt) { |
|
||||
excerpt = this.generateExcerpt(data.content) |
|
||||
} |
|
||||
|
|
||||
// 如果状态改为published,设置发布时间
|
|
||||
let publishedAt = data.published_at |
|
||||
if (data.status === "published" && current.status !== "published" && !publishedAt) { |
|
||||
publishedAt = db.fn.now() |
|
||||
} |
|
||||
|
|
||||
// 只更新数据库表中存在的字段
|
|
||||
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(updateData) |
|
||||
.returning("*"); |
|
||||
|
|
||||
return Array.isArray(result) ? result[0] : result // 确保返回单个对象
|
|
||||
} |
|
||||
|
|
||||
static async delete(id) { |
|
||||
const article = await db("articles").where("id", id).first() |
|
||||
if (!article) { |
|
||||
throw new Error("文章不存在") |
|
||||
} |
|
||||
return db("articles").where("id", id).del() |
|
||||
} |
|
||||
|
|
||||
static async publish(id) { |
|
||||
const result = await db("articles") |
|
||||
.where("id", id) |
|
||||
.update({ |
|
||||
status: "published", |
|
||||
published_at: db.fn.now(), |
|
||||
updated_at: db.fn.now(), |
|
||||
}) |
|
||||
.returning("*"); |
|
||||
|
|
||||
return Array.isArray(result) ? result[0] : result // 确保返回单个对象
|
|
||||
} |
|
||||
|
|
||||
static async unpublish(id) { |
|
||||
const result = await db("articles") |
|
||||
.where("id", id) |
|
||||
.update({ |
|
||||
status: "draft", |
|
||||
published_at: null, |
|
||||
updated_at: db.fn.now(), |
|
||||
}) |
|
||||
.returning("*"); |
|
||||
|
|
||||
return Array.isArray(result) ? result[0] : result // 确保返回单个对象
|
|
||||
} |
|
||||
|
|
||||
static async incrementViewCount(id) { |
|
||||
const result = await db("articles") |
|
||||
.where("id", id) |
|
||||
.increment("view_count", 1) |
|
||||
.returning("*"); |
|
||||
|
|
||||
return Array.isArray(result) ? result[0] : result // 确保返回单个对象
|
|
||||
} |
|
||||
|
|
||||
static async findByDateRange(startDate, endDate) { |
|
||||
return db("articles") |
|
||||
.where("status", "published") |
|
||||
.whereBetween("published_at", [startDate, endDate]) |
|
||||
.orderBy("published_at", "desc") |
|
||||
} |
|
||||
|
|
||||
static async getArticleCount() { |
|
||||
const result = await db("articles").count("id as count").first() |
|
||||
return result ? result.count : 0 |
|
||||
} |
|
||||
|
|
||||
static async getPublishedArticleCount() { |
|
||||
const result = await db("articles").where("status", "published").count("id as count").first() |
|
||||
return result ? result.count : 0 |
|
||||
} |
|
||||
|
|
||||
static async getArticleCountByCategory() { |
|
||||
return db("articles") |
|
||||
.select("category") |
|
||||
.count("id as count") |
|
||||
.where("status", "published") |
|
||||
.groupBy("category") |
|
||||
.orderBy("count", "desc") |
|
||||
} |
|
||||
|
|
||||
static async getArticleCountByStatus() { |
|
||||
return db("articles").select("status").count("id as count").groupBy("status").orderBy("count", "desc") |
|
||||
} |
|
||||
|
|
||||
static async getRecentArticles(limit = 10) { |
|
||||
return db("articles").where("status", "published").orderBy("published_at", "desc").limit(limit) |
|
||||
} |
|
||||
|
|
||||
static async getPopularArticles(limit = 10) { |
|
||||
return db("articles").where("status", "published").orderBy("view_count", "desc").limit(limit) |
|
||||
} |
|
||||
|
|
||||
static async getFeaturedArticles(limit = 5) { |
|
||||
return db("articles").where("status", "published").whereNotNull("featured_image").orderBy("published_at", "desc").limit(limit) |
|
||||
} |
|
||||
|
|
||||
static async getRelatedArticles(articleId, limit = 5) { |
|
||||
const current = await this.findById(articleId) |
|
||||
if (!current) return [] |
|
||||
|
|
||||
return db("articles") |
|
||||
.where("status", "published") |
|
||||
.where("id", "!=", articleId) |
|
||||
.where(function () { |
|
||||
if (current.category) { |
|
||||
this.orWhere("category", current.category) |
|
||||
} |
|
||||
if (current.tags) { |
|
||||
const tags = current.tags.split(",").map(tag => tag.trim()) |
|
||||
tags.forEach(tag => { |
|
||||
this.orWhereRaw("tags LIKE ?", [`%${tag}%`]) |
|
||||
}) |
|
||||
} |
|
||||
}) |
|
||||
.orderBy("published_at", "desc") |
|
||||
.limit(limit) |
|
||||
} |
|
||||
|
|
||||
// 工具方法
|
|
||||
static generateSlug(title) { |
|
||||
return title |
|
||||
.toLowerCase() |
|
||||
.replace(/[^\w\s-]/g, "") |
|
||||
.replace(/\s+/g, "-") |
|
||||
.replace(/-+/g, "-") |
|
||||
.trim() |
|
||||
} |
|
||||
|
|
||||
static calculateReadingTime(content) { |
|
||||
// 假设平均阅读速度为每分钟200个单词
|
|
||||
const wordCount = content.split(/\s+/).length |
|
||||
return Math.ceil(wordCount / 200) |
|
||||
} |
|
||||
|
|
||||
static generateExcerpt(content, maxLength = 150) { |
|
||||
if (content.length <= maxLength) { |
|
||||
return content |
|
||||
} |
|
||||
return content.substring(0, maxLength).trim() + "..." |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default ArticleModel |
|
||||
export { ArticleModel } |
|
||||
@ -1,158 +0,0 @@ |
|||||
import BaseModel, { handleDatabaseError } from "./BaseModel.js" |
|
||||
|
|
||||
class BookmarkModel extends BaseModel { |
|
||||
static get tableName() { |
|
||||
return "bookmarks" |
|
||||
} |
|
||||
|
|
||||
static get searchableFields() { |
|
||||
return ["title", "url", "description"] |
|
||||
} |
|
||||
|
|
||||
static get filterableFields() { |
|
||||
return ["user_id"] |
|
||||
} |
|
||||
|
|
||||
// 特定业务方法
|
|
||||
static async findAllByUser(userId) { |
|
||||
return this.findWhere({ user_id: userId }, { orderBy: "id", order: "desc" }) |
|
||||
} |
|
||||
|
|
||||
static async findByUserAndUrl(userId, url) { |
|
||||
return this.findFirst({ user_id: userId, url }) |
|
||||
} |
|
||||
|
|
||||
// 重写create方法添加验证
|
|
||||
static async create(data) { |
|
||||
const userId = data.user_id |
|
||||
const url = typeof data.url === "string" ? data.url.trim() : data.url |
|
||||
|
|
||||
if (userId != null && url) { |
|
||||
const exists = await this.findByUserAndUrl(userId, url) |
|
||||
if (exists) { |
|
||||
throw new Error("该用户下已存在相同 URL 的书签") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return super.create({ ...data, url }) |
|
||||
} |
|
||||
|
|
||||
// 重写update方法添加验证
|
|
||||
static async update(id, data) { |
|
||||
const current = await this.findById(id) |
|
||||
if (!current) return null |
|
||||
|
|
||||
const nextUserId = data.user_id != null ? data.user_id : current.user_id |
|
||||
const nextUrlRaw = data.url != null ? data.url : current.url |
|
||||
const nextUrl = typeof nextUrlRaw === "string" ? nextUrlRaw.trim() : nextUrlRaw |
|
||||
|
|
||||
if (nextUserId != null && nextUrl) { |
|
||||
const exists = await this.findFirst({ |
|
||||
user_id: nextUserId, |
|
||||
url: nextUrl |
|
||||
}) |
|
||||
// 排除当前记录
|
|
||||
if (exists && exists.id !== parseInt(id)) { |
|
||||
throw new Error("该用户下已存在相同 URL 的书签") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return super.update(id, { |
|
||||
...data, |
|
||||
url: data.url != null ? nextUrl : data.url |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
// 获取用户书签统计
|
|
||||
static async getUserBookmarkStats(userId) { |
|
||||
const total = await this.count({ user_id: userId }) |
|
||||
return { total } |
|
||||
} |
|
||||
|
|
||||
// 按用户分页查询书签
|
|
||||
static async findByUserWithPagination(userId, options = {}) { |
|
||||
return this.paginate({ |
|
||||
...options, |
|
||||
where: { user_id: userId } |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
// 标记为已回复
|
|
||||
static async markAsReplied(id) { |
|
||||
return this.update(id, { status: "replied" }) |
|
||||
} |
|
||||
|
|
||||
// ==================== 新增关联查询方法 ====================
|
|
||||
|
|
||||
/** |
|
||||
* 获取用户书签(包含用户信息) |
|
||||
*/ |
|
||||
static async findByUserWithProfile(userId) { |
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['bookmarks.user_id', 'users.id'], |
|
||||
select: [ |
|
||||
'bookmarks.*', |
|
||||
'users.username', |
|
||||
'users.name as user_name', |
|
||||
'users.avatar as user_avatar' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{ 'bookmarks.user_id': userId }, |
|
||||
relations, |
|
||||
{ orderBy: 'bookmarks.created_at', order: 'desc' } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取所有书签及其用户信息 |
|
||||
*/ |
|
||||
static async findAllWithUsers(options = {}) { |
|
||||
const { limit = 50, orderBy = 'created_at', order = 'desc' } = options |
|
||||
|
|
||||
const relations = [{ |
|
||||
type: 'left', |
|
||||
table: 'users', |
|
||||
on: ['bookmarks.user_id', 'users.id'], |
|
||||
select: [ |
|
||||
'bookmarks.*', |
|
||||
'users.username', |
|
||||
'users.name as user_name' |
|
||||
] |
|
||||
}] |
|
||||
|
|
||||
return this.findWithRelations( |
|
||||
{}, |
|
||||
relations, |
|
||||
{ orderBy: `bookmarks.${orderBy}`, order, limit } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取热门书签(按用户数量统计) |
|
||||
*/ |
|
||||
static async getPopularBookmarks(limit = 10) { |
|
||||
try { |
|
||||
return await db(this.tableName) |
|
||||
.select( |
|
||||
'url', |
|
||||
'title', |
|
||||
db.raw('COUNT(*) as bookmark_count'), |
|
||||
db.raw('MAX(created_at) as latest_bookmark') |
|
||||
) |
|
||||
.groupBy('url', 'title') |
|
||||
.orderBy('bookmark_count', 'desc') |
|
||||
.limit(limit) |
|
||||
} catch (error) { |
|
||||
throw handleDatabaseError(error, `获取热门书签`) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default BookmarkModel |
|
||||
export { BookmarkModel } |
|
||||
|
|
||||
|
|
||||
@ -1,132 +0,0 @@ |
|||||
import BaseModel, { handleDatabaseError } from "./BaseModel.js" |
|
||||
import db from "../index.js" |
|
||||
|
|
||||
class ContactModel extends BaseModel { |
|
||||
static get tableName() { |
|
||||
return "contacts" |
|
||||
} |
|
||||
|
|
||||
static get searchableFields() { |
|
||||
return ["name", "email", "subject", "message"] |
|
||||
} |
|
||||
|
|
||||
static get filterableFields() { |
|
||||
return ["status"] |
|
||||
} |
|
||||
|
|
||||
static get defaultOrderBy() { |
|
||||
return "created_at" |
|
||||
} |
|
||||
|
|
||||
// 获取db实例
|
|
||||
static get db() { |
|
||||
return db |
|
||||
} |
|
||||
|
|
||||
// 特定业务方法
|
|
||||
static async findByEmail(email) { |
|
||||
return this.findWhere({ email }, { orderBy: "created_at", order: "desc" }) |
|
||||
} |
|
||||
|
|
||||
static async findByStatus(status) { |
|
||||
return this.findWhere({ status }, { orderBy: "created_at", order: "desc" }) |
|
||||
} |
|
||||
|
|
||||
static async findByDateRange(startDate, endDate) { |
|
||||
try { |
|
||||
const query = this.findWhere({}) |
|
||||
return await query.whereBetween('created_at', [startDate, endDate]) |
|
||||
} catch (error) { |
|
||||
throw handleDatabaseError(error, `按日期范围查找${this.tableName}记录`) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取联系信息统计
|
|
||||
static async getStats() { |
|
||||
const total = await this.count() |
|
||||
const unread = await this.count({ status: "unread" }) |
|
||||
const read = await this.count({ status: "read" }) |
|
||||
const replied = await this.count({ status: "replied" }) |
|
||||
|
|
||||
return { |
|
||||
total, |
|
||||
unread, |
|
||||
read, |
|
||||
replied |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 批量更新状态
|
|
||||
static async updateStatusBatch(ids, status) { |
|
||||
return this.updateMany( |
|
||||
{ id: ids }, // 这里需要使用whereIn,但BaseModel的updateMany不支持
|
|
||||
{ status } |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
// 重写以支持whereIn操作
|
|
||||
static async updateStatusBatchByIds(ids, status) { |
|
||||
try { |
|
||||
return await db(this.tableName) |
|
||||
.whereIn("id", ids) |
|
||||
.update({ |
|
||||
status, |
|
||||
updated_at: db.fn.now() |
|
||||
}) |
|
||||
} catch (error) { |
|
||||
throw handleDatabaseError(error, `批量更新${this.tableName}状态`) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 分页查询重写,使用父类方法
|
|
||||
static async findAllWithPagination(options = {}) { |
|
||||
const { |
|
||||
page = 1, |
|
||||
limit = 20, |
|
||||
status = null, |
|
||||
orderBy = 'created_at', |
|
||||
order = 'desc' |
|
||||
} = options |
|
||||
|
|
||||
const where = status ? { status } : {} |
|
||||
|
|
||||
return this.paginate({ |
|
||||
page, |
|
||||
limit, |
|
||||
where, |
|
||||
orderBy, |
|
||||
order |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
// 获取今日新联系数量
|
|
||||
static async getTodayCount() { |
|
||||
const today = new Date() |
|
||||
today.setHours(0, 0, 0, 0) |
|
||||
const tomorrow = new Date(today) |
|
||||
tomorrow.setDate(tomorrow.getDate() + 1) |
|
||||
|
|
||||
try { |
|
||||
const result = await db(this.tableName) |
|
||||
.whereBetween('created_at', [today, tomorrow]) |
|
||||
.count('id as count') |
|
||||
.first() |
|
||||
return parseInt(result.count) || 0 |
|
||||
} catch (error) { |
|
||||
throw handleDatabaseError(error, `获取今日${this.tableName}数量`) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 标记为已读
|
|
||||
static async markAsRead(id) { |
|
||||
return this.update(id, { status: "read" }) |
|
||||
} |
|
||||
|
|
||||
// 标记为已回复
|
|
||||
static async markAsReplied(id) { |
|
||||
return this.update(id, { status: "replied" }) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default ContactModel |
|
||||
export { ContactModel } |
|
||||
@ -1,77 +0,0 @@ |
|||||
/** |
|
||||
* @param { import("knex").Knex } knex |
|
||||
* @returns { Promise<void> } |
|
||||
*/ |
|
||||
export const seed = async knex => { |
|
||||
// 清空表
|
|
||||
await knex("articles").del() |
|
||||
|
|
||||
// 插入示例数据
|
|
||||
await knex("articles").insert([ |
|
||||
{ |
|
||||
title: "欢迎使用文章管理系统", |
|
||||
content: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理。系统提供了丰富的功能,包括标签管理、分类管理、SEO优化等。\n\n## 主要特性\n\n- 支持Markdown格式\n- 标签和分类管理\n- SEO优化\n- 阅读时间计算\n- 浏览量统计\n- 草稿和发布状态管理", |
|
||||
author: "系统管理员", |
|
||||
category: "系统介绍", |
|
||||
tags: "系统, 介绍, 功能", |
|
||||
keywords: "文章管理, 系统介绍, 功能特性", |
|
||||
description: "介绍文章管理系统的主要功能和特性", |
|
||||
status: "published", |
|
||||
published_at: knex.fn.now(), |
|
||||
excerpt: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理...", |
|
||||
reading_time: 3, |
|
||||
slug: "welcome-to-article-management-system", |
|
||||
meta_title: "欢迎使用文章管理系统 - 功能特性介绍", |
|
||||
meta_description: "了解文章管理系统的主要功能,包括Markdown支持、标签管理、SEO优化等特性" |
|
||||
}, |
|
||||
{ |
|
||||
title: "Markdown 写作指南", |
|
||||
content: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档。\n\n## 基本语法\n\n### 标题\n使用 `#` 符号创建标题:\n\n```markdown\n# 一级标题\n## 二级标题\n### 三级标题\n```\n\n### 列表\n- 无序列表使用 `-` 或 `*`\n- 有序列表使用数字\n\n### 链接和图片\n[链接文本](URL)\n\n\n### 代码\n使用反引号标记行内代码:`code`\n\n使用代码块:\n```javascript\nfunction hello() {\n console.log('Hello World!');\n}\n```", |
|
||||
author: "技术编辑", |
|
||||
category: "写作指南", |
|
||||
tags: "Markdown, 写作, 指南", |
|
||||
keywords: "Markdown, 写作指南, 语法, 教程", |
|
||||
description: "详细介绍Markdown的基本语法和用法,帮助用户快速掌握Markdown写作", |
|
||||
status: "published", |
|
||||
published_at: knex.fn.now(), |
|
||||
excerpt: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档...", |
|
||||
reading_time: 8, |
|
||||
slug: "markdown-writing-guide", |
|
||||
meta_title: "Markdown 写作指南 - 从入门到精通", |
|
||||
meta_description: "学习Markdown的基本语法,包括标题、列表、链接、图片、代码等常用元素的写法" |
|
||||
}, |
|
||||
{ |
|
||||
title: "SEO 优化最佳实践", |
|
||||
content: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。\n\n## 关键词研究\n\n关键词研究是SEO的基础,需要:\n- 了解目标受众的搜索习惯\n- 分析竞争对手的关键词\n- 选择合适的关键词密度\n\n## 内容优化\n\n### 标题优化\n- 标题应包含主要关键词\n- 标题长度控制在50-60字符\n- 使用吸引人的标题\n\n### 内容结构\n- 使用H1-H6标签组织内容\n- 段落要简洁明了\n- 添加相关图片和视频\n\n## 技术SEO\n\n- 确保网站加载速度快\n- 优化移动端体验\n- 使用结构化数据\n- 建立内部链接结构", |
|
||||
author: "SEO专家", |
|
||||
category: "数字营销", |
|
||||
tags: "SEO, 优化, 搜索引擎, 营销", |
|
||||
keywords: "SEO优化, 搜索引擎优化, 关键词研究, 内容优化", |
|
||||
description: "介绍SEO优化的最佳实践,包括关键词研究、内容优化和技术SEO等方面", |
|
||||
status: "published", |
|
||||
published_at: knex.fn.now(), |
|
||||
excerpt: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。本文介绍SEO优化的最佳实践...", |
|
||||
reading_time: 12, |
|
||||
slug: "seo-optimization-best-practices", |
|
||||
meta_title: "SEO 优化最佳实践 - 提升网站排名", |
|
||||
meta_description: "学习SEO优化的关键技巧,包括关键词研究、内容优化和技术SEO,帮助提升网站在搜索引擎中的排名" |
|
||||
}, |
|
||||
{ |
|
||||
title: "前端开发趋势 2024", |
|
||||
content: "2024年前端开发领域出现了许多新的趋势和技术。\n\n## 主要趋势\n\n### 1. 框架发展\n- React 18的新特性\n- Vue 3的Composition API\n- Svelte的崛起\n\n### 2. 构建工具\n- Vite的快速构建\n- Webpack 5的模块联邦\n- Turbopack的性能提升\n\n### 3. 性能优化\n- 核心Web指标\n- 图片优化\n- 代码分割\n\n### 4. 新特性\n- CSS容器查询\n- CSS Grid布局\n- Web Components\n\n## 学习建议\n\n建议开发者关注这些趋势,但不要盲目追新,要根据项目需求选择合适的技术栈。", |
|
||||
author: "前端开发者", |
|
||||
category: "技术趋势", |
|
||||
tags: "前端, 开发, 趋势, 2024", |
|
||||
keywords: "前端开发, 技术趋势, React, Vue, 性能优化", |
|
||||
description: "分析2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等方面", |
|
||||
status: "draft", |
|
||||
excerpt: "2024年前端开发领域出现了许多新的趋势和技术。本文分析主要趋势并提供学习建议...", |
|
||||
reading_time: 10, |
|
||||
slug: "frontend-development-trends-2024", |
|
||||
meta_title: "前端开发趋势 2024 - 技术发展分析", |
|
||||
meta_description: "了解2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等,为技术选型提供参考" |
|
||||
} |
|
||||
]) |
|
||||
|
|
||||
console.log("✅ Articles seeded successfully!") |
|
||||
} |
|
||||
@ -0,0 +1,43 @@ |
|||||
|
import Router from "utils/router.js" |
||||
|
import { logger } from "@/logger.js" |
||||
|
import BaseController from "@/base/BaseController.js" |
||||
|
import UserService from "@/modules/Auth/services" |
||||
|
import UserModel from "@/modules/Auth/model/user" |
||||
|
|
||||
|
export default class AuthController extends BaseController { |
||||
|
/** |
||||
|
* 创建基础页面相关路由 |
||||
|
* @returns {Router} 路由实例 |
||||
|
*/ |
||||
|
static createRoutes() { |
||||
|
const controller = new this() |
||||
|
const router = new Router({ auth: true, prefix: "/admin" }) |
||||
|
|
||||
|
router.get("/", controller.handleRequest(controller.indexGet)) |
||||
|
router.get("/profile", controller.handleRequest(controller.profileGet)) |
||||
|
router.get("", controller.handleRequest(controller.indexGet)) |
||||
|
router.post("/profile/update", controller.handleRequest(controller.profileUpdate)) |
||||
|
|
||||
|
return router |
||||
|
} |
||||
|
|
||||
|
async indexGet(ctx) { |
||||
|
return this.render(ctx, "page/admin/index/index", {}) |
||||
|
} |
||||
|
|
||||
|
async profileGet(ctx) { |
||||
|
return this.render(ctx, "page/admin/profile/index", { |
||||
|
user: ctx.state.user, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
async profileUpdate(ctx) { |
||||
|
await UserService.update(ctx.state.user.id, ctx.request.body) |
||||
|
ctx.state.user = await UserService.findById(ctx.state.user.id) |
||||
|
if(ctx.session.user) { |
||||
|
ctx.session.user = ctx.state.user |
||||
|
} |
||||
|
ctx.status = 200 |
||||
|
ctx.set("HX-Redirect", "/admin/profile") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
import Router from "utils/router.js" |
||||
|
import { logger } from "@/logger.js" |
||||
|
import BaseController from "@/base/BaseController.js" |
||||
|
import AuthService from "../services" |
||||
|
|
||||
|
export default class AuthController extends BaseController { |
||||
|
/** |
||||
|
* 创建基础页面相关路由 |
||||
|
* @returns {Router} 路由实例 |
||||
|
*/ |
||||
|
static createRoutes() { |
||||
|
const controller = new this() |
||||
|
const router = new Router({ auth: false }) |
||||
|
|
||||
|
router.get("/register", controller.handleRequest(controller.registerGet)) |
||||
|
router.post("/register", controller.handleRequest(controller.registerPost)) |
||||
|
|
||||
|
router.post("/register/validate/username", controller.handleRequest(controller.validateUsername)) |
||||
|
router.post("/register/validate/password", controller.handleRequest(controller.validatePassword)) |
||||
|
router.post("/register/validate/confirmPassword", controller.handleRequest(controller.validateConfirmPassword)) |
||||
|
|
||||
|
return router |
||||
|
} |
||||
|
|
||||
|
constructor() { |
||||
|
super() |
||||
|
} |
||||
|
|
||||
|
// 首页
|
||||
|
async registerGet(ctx) { |
||||
|
return this.render(ctx, "page/register/index", {}) |
||||
|
} |
||||
|
|
||||
|
async registerPost(ctx) { |
||||
|
const { username, password, confirmPassword, nickname } = ctx.request.body |
||||
|
if (password !== confirmPassword) { |
||||
|
return this.render(ctx, "page/register/_ui/confirmPassword", { |
||||
|
value: confirmPassword, |
||||
|
error: "确认密码与密码不一致", |
||||
|
}) |
||||
|
} |
||||
|
const res = await AuthService.register({ username, password, nickname, role: "user" }) |
||||
|
ctx.set("HX-Redirect", "/") |
||||
|
} |
||||
|
|
||||
|
async validateUsername(ctx) { |
||||
|
const { username } = ctx.request.body |
||||
|
const uiPath = "page/register/_ui/username" |
||||
|
if (username === "") { |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: username, |
||||
|
error: "用户名不能为空", |
||||
|
}) |
||||
|
} |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: username, |
||||
|
error: undefined, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
async validateConfirmPassword(ctx) { |
||||
|
const { confirmPassword, password } = ctx.request.body |
||||
|
const uiPath = "page/register/_ui/confirmPassword" |
||||
|
if (confirmPassword === "") { |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: confirmPassword, |
||||
|
error: "确认密码不能为空", |
||||
|
}) |
||||
|
} |
||||
|
if (confirmPassword !== password) { |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: confirmPassword, |
||||
|
error: "确认密码与密码不一致", |
||||
|
}) |
||||
|
} |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: confirmPassword, |
||||
|
error: undefined, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
async validatePassword(ctx) { |
||||
|
const { password } = ctx.request.body |
||||
|
const uiPath = "page/register/_ui/password" |
||||
|
if (password === "") { |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: password, |
||||
|
error: "密码不能为空", |
||||
|
}) |
||||
|
} |
||||
|
return this.render(ctx, uiPath, { |
||||
|
value: password, |
||||
|
error: undefined, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
async logout(ctx) { |
||||
|
ctx.session.user = null |
||||
|
ctx.set("HX-Redirect", "/") |
||||
|
} |
||||
|
} |
||||
@ -1,5 +1,5 @@ |
|||||
import BaseModel from "./BaseModel.js" |
import BaseModel from "@/base/BaseModel.js" |
||||
import db from "../index.js" |
import db from "@/db" |
||||
|
|
||||
class SiteConfigModel extends BaseModel { |
class SiteConfigModel extends BaseModel { |
||||
static get tableName() { |
static get tableName() { |
||||
@ -1,22 +1,15 @@ |
|||||
mixin include() |
|
||||
if block |
|
||||
block |
|
||||
//- include的使用方法 |
|
||||
//- +include() |
|
||||
//- - var edit = false |
|
||||
//- include /htmx/footer.pug |
|
||||
|
|
||||
mixin css(url, extranl = false) |
mixin css(url, extranl = false) |
||||
if extranl || url.startsWith('http') || url.startsWith('//') |
if extranl || url.startsWith('http') || url.startsWith('//') |
||||
link(rel="stylesheet" type="text/css" href=url) |
link(rel="stylesheet" type="text/css" href=url) |
||||
else |
else |
||||
link(rel="stylesheet", href=($config && $config.base || "") + "public/"+ (url.startsWith('/') ? url.slice(1) : url)) |
link(rel="stylesheet", href=(siteConfig && siteConfig.site_base || "") + "public/"+ (url.startsWith('/') ? url.slice(1) : url)) |
||||
|
|
||||
mixin js(url, extranl = false) |
mixin js(url, extranl = false) |
||||
if extranl || url.startsWith('http') || url.startsWith('//') |
if extranl || url.startsWith('http') || url.startsWith('//') |
||||
script(type="text/javascript" src=url) |
script(type="text/javascript" src=url) |
||||
else |
else |
||||
script(src=($config && $config.base || "") + "public/" + (url.startsWith('/') ? url.slice(1) : url)) |
script(src=(siteConfig && siteConfig.site_base || "") + "public/" + (url.startsWith('/') ? url.slice(1) : url)) |
||||
|
|
||||
mixin link(href, name) |
mixin link(href, name) |
||||
//- attributes == {class: "btn"} |
//- attributes == {class: "btn"} |
||||
|
|||||
@ -0,0 +1,33 @@ |
|||||
|
extends /layouts/root.pug |
||||
|
|
||||
|
block $$head |
||||
|
style. |
||||
|
.page-layout { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
width: 100%; |
||||
|
position: relative; |
||||
|
} |
||||
|
block Head |
||||
|
|
||||
|
block $$content |
||||
|
.page-layout.bg-gray-50 |
||||
|
canvas#background.absolute.block.top-0.left-0.z-0 |
||||
|
.h-full.relative.flex |
||||
|
.left-sidebar.border.border-r.border-gray-200(class="w-[250px]") |
||||
|
a.text-center.text-2xl.font-bold.mb-4(class="h-[75px] block leading-[75px]" href="/") 烟霞渡 |
||||
|
a(href="/admin" class=`cursor-pointer block h-[75px] leading-[75px] text-center hover:bg-gray-100 ${(currentPath === "/admin" || currentPath === "/admin/") ? "bg-gray-100" : ""}`) 仪表板 |
||||
|
a(href="/admin/profile" class=`cursor-pointer block h-[75px] leading-[75px] text-center hover:bg-gray-100 ${(currentPath === "/admin/profile" || currentPath === "/admin/profile/") ? "bg-gray-100" : ""}`) 用户信息 |
||||
|
.right-content(class="flex-1") |
||||
|
block Content |
||||
|
|
||||
|
block $$scripts |
||||
|
+js("https://cdnjs.cloudflare.com/ajax/libs/particlesjs/2.2.2/particles.min.js") |
||||
|
script. |
||||
|
Particles.init({ |
||||
|
selector: '#background', |
||||
|
maxParticles: 350, |
||||
|
}); |
||||
|
block Scripts |
||||
|
|
||||
@ -0,0 +1,8 @@ |
|||||
|
extends /layouts/admin.pug |
||||
|
|
||||
|
block Head |
||||
|
style |
||||
|
include ./style.css |
||||
|
|
||||
|
block Content |
||||
|
div |
||||
@ -0,0 +1,398 @@ |
|||||
|
extends /layouts/admin.pug |
||||
|
|
||||
|
block Head |
||||
|
link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css") |
||||
|
style |
||||
|
include ./style.css |
||||
|
|
||||
|
block Content |
||||
|
.profile-container |
||||
|
.profile-content |
||||
|
.profile-card |
||||
|
.card-header |
||||
|
.avatar-section |
||||
|
if user.avatar |
||||
|
img.avatar-preview(src=user.avatar alt="用户头像") |
||||
|
else |
||||
|
.avatar-placeholder |
||||
|
i.fas.fa-user |
||||
|
.avatar-info |
||||
|
h3 头像预览 |
||||
|
p 点击下方输入框更新头像链接 |
||||
|
|
||||
|
form.profile-form(hx-post="/admin/profile/update" hx-target="#profile-result") |
||||
|
.form-grid |
||||
|
.form-group |
||||
|
label.form-label(for="username") |
||||
|
i.fas.fa-user |
||||
|
span 用户名 |
||||
|
input.form-input( |
||||
|
type="text" |
||||
|
id="username" |
||||
|
name="username" |
||||
|
placeholder="请输入用户名" |
||||
|
value=user.username |
||||
|
required |
||||
|
) |
||||
|
.form-hint 用于登录的唯一标识符 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="email") |
||||
|
i.fas.fa-envelope |
||||
|
span 邮箱地址 |
||||
|
input.form-input( |
||||
|
type="email" |
||||
|
id="email" |
||||
|
name="email" |
||||
|
placeholder="请输入邮箱地址" |
||||
|
value=user.email |
||||
|
required |
||||
|
) |
||||
|
.form-hint 用于接收系统通知和重置密码 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="nickname") |
||||
|
i.fas.fa-id-card |
||||
|
span 昵称 |
||||
|
input.form-input( |
||||
|
type="text" |
||||
|
id="nickname" |
||||
|
name="nickname" |
||||
|
placeholder="请输入昵称" |
||||
|
value=user.nickname |
||||
|
) |
||||
|
.form-hint 显示给其他用户的友好名称 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="phone") |
||||
|
i.fas.fa-phone |
||||
|
span 联系电话 |
||||
|
input.form-input( |
||||
|
type="tel" |
||||
|
id="phone" |
||||
|
name="phone" |
||||
|
placeholder="请输入联系电话" |
||||
|
value=user.phone |
||||
|
) |
||||
|
.form-hint 用于紧急联系和重要通知 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="age") |
||||
|
i.fas.fa-birthday-cake |
||||
|
span 年龄 |
||||
|
input.form-input( |
||||
|
type="number" |
||||
|
id="age" |
||||
|
name="age" |
||||
|
placeholder="请输入年龄" |
||||
|
value=user.age |
||||
|
min="1" |
||||
|
max="120" |
||||
|
) |
||||
|
.form-hint 用于个性化推荐和统计分析 |
||||
|
|
||||
|
.form-group.full-width |
||||
|
label.form-label(for="bio") |
||||
|
i.fas.fa-file-text |
||||
|
span 个人简介 |
||||
|
textarea.form-textarea( |
||||
|
id="bio" |
||||
|
name="bio" |
||||
|
placeholder="请介绍一下自己..." |
||||
|
rows="4" |
||||
|
)= user.bio |
||||
|
.form-hint 让其他用户了解您的背景和兴趣 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="avatar") |
||||
|
i.fas.fa-image |
||||
|
span 头像链接 |
||||
|
input.form-input( |
||||
|
type="url" |
||||
|
id="avatar" |
||||
|
name="avatar" |
||||
|
placeholder="请输入头像图片链接" |
||||
|
value=user.avatar |
||||
|
) |
||||
|
.form-hint 支持 JPG、PNG、GIF 格式的图片链接 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="status") |
||||
|
i.fas.fa-toggle-on |
||||
|
span 账户状态 |
||||
|
select.form-select( |
||||
|
id="status" |
||||
|
name="status" |
||||
|
) |
||||
|
option(value="active" selected=user.status === 'active') 活跃 |
||||
|
option(value="inactive" selected=user.status === 'inactive') 非活跃 |
||||
|
option(value="suspended" selected=user.status === 'suspended') 已暂停 |
||||
|
.form-hint 控制账户的可用状态 |
||||
|
|
||||
|
.form-group |
||||
|
label.form-label(for="role") |
||||
|
i.fas.fa-user-tag |
||||
|
span 用户角色 |
||||
|
select.form-select( |
||||
|
id="role" |
||||
|
name="role" |
||||
|
) |
||||
|
option(value="user" selected=user.role === 'user') 普通用户 |
||||
|
option(value="admin" selected=user.role === 'admin') 管理员 |
||||
|
option(value="moderator" selected=user.role === 'moderator') 版主 |
||||
|
.form-hint 决定用户的权限级别 |
||||
|
|
||||
|
.form-actions |
||||
|
button.btn.btn-primary(type="submit") |
||||
|
i.fas.fa-save |
||||
|
span 保存更改 |
||||
|
button.btn.btn-secondary(type="button" onclick="history.back()") |
||||
|
i.fas.fa-arrow-left |
||||
|
span 返回 |
||||
|
|
||||
|
#profile-result.profile-result |
||||
|
|
||||
|
block Scripts |
||||
|
script. |
||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||
|
// 头像预览功能 |
||||
|
const avatarInput = document.getElementById('avatar'); |
||||
|
const avatarPreview = document.querySelector('.avatar-preview'); |
||||
|
const avatarPlaceholder = document.querySelector('.avatar-placeholder'); |
||||
|
|
||||
|
if (avatarInput) { |
||||
|
avatarInput.addEventListener('input', function() { |
||||
|
const avatarUrl = this.value.trim(); |
||||
|
if (avatarUrl) { |
||||
|
if (avatarPreview) { |
||||
|
avatarPreview.src = avatarUrl; |
||||
|
avatarPreview.style.display = 'block'; |
||||
|
if (avatarPlaceholder) { |
||||
|
avatarPlaceholder.style.display = 'none'; |
||||
|
} |
||||
|
} else { |
||||
|
// 创建新的头像预览 |
||||
|
const newPreview = document.createElement('img'); |
||||
|
newPreview.className = 'avatar-preview'; |
||||
|
newPreview.src = avatarUrl; |
||||
|
newPreview.alt = '用户头像'; |
||||
|
avatarPlaceholder.parentNode.insertBefore(newPreview, avatarPlaceholder); |
||||
|
avatarPlaceholder.style.display = 'none'; |
||||
|
} |
||||
|
} else { |
||||
|
if (avatarPreview) { |
||||
|
avatarPreview.style.display = 'none'; |
||||
|
} |
||||
|
if (avatarPlaceholder) { |
||||
|
avatarPlaceholder.style.display = 'flex'; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 表单验证 |
||||
|
const form = document.querySelector('.profile-form'); |
||||
|
if (form) { |
||||
|
form.addEventListener('submit', function(e) { |
||||
|
e.preventDefault(); |
||||
|
|
||||
|
// 清除之前的验证状态 |
||||
|
clearValidationStates(); |
||||
|
|
||||
|
// 验证必填字段 |
||||
|
const username = document.getElementById('username'); |
||||
|
const email = document.getElementById('email'); |
||||
|
|
||||
|
let isValid = true; |
||||
|
|
||||
|
if (username && !username.value.trim()) { |
||||
|
showFieldError(username, '用户名不能为空'); |
||||
|
isValid = false; |
||||
|
} |
||||
|
|
||||
|
if (email && !email.value.trim()) { |
||||
|
showFieldError(email, '邮箱不能为空'); |
||||
|
isValid = false; |
||||
|
} else if (email && !isValidEmail(email.value)) { |
||||
|
showFieldError(email, '请输入有效的邮箱地址'); |
||||
|
isValid = false; |
||||
|
} |
||||
|
|
||||
|
// 验证年龄 |
||||
|
const age = document.getElementById('age'); |
||||
|
if (age && age.value && (isNaN(age.value) || age.value < 1 || age.value > 120)) { |
||||
|
showFieldError(age, '年龄必须在1-120之间'); |
||||
|
isValid = false; |
||||
|
} |
||||
|
|
||||
|
// 验证头像URL |
||||
|
const avatar = document.getElementById('avatar'); |
||||
|
if (avatar && avatar.value && !isValidUrl(avatar.value)) { |
||||
|
showFieldError(avatar, '请输入有效的图片链接'); |
||||
|
isValid = false; |
||||
|
} |
||||
|
|
||||
|
if (isValid) { |
||||
|
// 显示加载状态 |
||||
|
const submitBtn = form.querySelector('button[type="submit"]'); |
||||
|
if (submitBtn) { |
||||
|
submitBtn.classList.add('loading'); |
||||
|
submitBtn.disabled = true; |
||||
|
} |
||||
|
|
||||
|
// 提交表单 |
||||
|
htmx.ajax('POST', '/admin/profile/update', { |
||||
|
values: new FormData(form), |
||||
|
target: '#profile-result', |
||||
|
swap: 'innerHTML' |
||||
|
}).then(() => { |
||||
|
// 恢复按钮状态 |
||||
|
if (submitBtn) { |
||||
|
submitBtn.classList.remove('loading'); |
||||
|
submitBtn.disabled = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 实时验证 |
||||
|
const inputs = document.querySelectorAll('.form-input, .form-textarea, .form-select'); |
||||
|
inputs.forEach(input => { |
||||
|
input.addEventListener('blur', function() { |
||||
|
validateField(this); |
||||
|
}); |
||||
|
|
||||
|
input.addEventListener('input', function() { |
||||
|
if (this.classList.contains('error')) { |
||||
|
validateField(this); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
// 字段验证函数 |
||||
|
function validateField(field) { |
||||
|
const value = field.value.trim(); |
||||
|
const fieldName = field.name; |
||||
|
|
||||
|
clearFieldError(field); |
||||
|
|
||||
|
switch (fieldName) { |
||||
|
case 'username': |
||||
|
if (!value) { |
||||
|
showFieldError(field, '用户名不能为空'); |
||||
|
} else if (value.length < 3) { |
||||
|
showFieldError(field, '用户名至少需要3个字符'); |
||||
|
} else { |
||||
|
showFieldSuccess(field); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case 'email': |
||||
|
if (!value) { |
||||
|
showFieldError(field, '邮箱不能为空'); |
||||
|
} else if (!isValidEmail(value)) { |
||||
|
showFieldError(field, '请输入有效的邮箱地址'); |
||||
|
} else { |
||||
|
showFieldSuccess(field); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case 'age': |
||||
|
if (value && (isNaN(value) || value < 1 || value > 120)) { |
||||
|
showFieldError(field, '年龄必须在1-120之间'); |
||||
|
} else if (value) { |
||||
|
showFieldSuccess(field); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case 'avatar': |
||||
|
if (value && !isValidUrl(value)) { |
||||
|
showFieldError(field, '请输入有效的图片链接'); |
||||
|
} else if (value) { |
||||
|
showFieldSuccess(field); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 显示字段错误 |
||||
|
function showFieldError(field, message) { |
||||
|
field.classList.add('error'); |
||||
|
field.classList.remove('success'); |
||||
|
|
||||
|
// 移除之前的错误提示 |
||||
|
const existingError = field.parentNode.querySelector('.field-error'); |
||||
|
if (existingError) { |
||||
|
existingError.remove(); |
||||
|
} |
||||
|
|
||||
|
// 添加新的错误提示 |
||||
|
const errorDiv = document.createElement('div'); |
||||
|
errorDiv.className = 'field-error'; |
||||
|
errorDiv.style.color = '#ef4444'; |
||||
|
errorDiv.style.fontSize = '0.8rem'; |
||||
|
errorDiv.style.marginTop = '0.25rem'; |
||||
|
errorDiv.textContent = message; |
||||
|
field.parentNode.appendChild(errorDiv); |
||||
|
} |
||||
|
|
||||
|
// 显示字段成功 |
||||
|
function showFieldSuccess(field) { |
||||
|
field.classList.remove('error'); |
||||
|
field.classList.add('success'); |
||||
|
|
||||
|
// 移除错误提示 |
||||
|
const existingError = field.parentNode.querySelector('.field-error'); |
||||
|
if (existingError) { |
||||
|
existingError.remove(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 清除字段错误 |
||||
|
function clearFieldError(field) { |
||||
|
field.classList.remove('error', 'success'); |
||||
|
const existingError = field.parentNode.querySelector('.field-error'); |
||||
|
if (existingError) { |
||||
|
existingError.remove(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 清除所有验证状态 |
||||
|
function clearValidationStates() { |
||||
|
inputs.forEach(input => { |
||||
|
clearFieldError(input); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 验证邮箱格式 |
||||
|
function isValidEmail(email) { |
||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |
||||
|
return emailRegex.test(email); |
||||
|
} |
||||
|
|
||||
|
// 验证URL格式 |
||||
|
function isValidUrl(url) { |
||||
|
try { |
||||
|
new URL(url); |
||||
|
return true; |
||||
|
} catch { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理HTMX响应 |
||||
|
document.body.addEventListener('htmx:afterRequest', function(event) { |
||||
|
const resultDiv = document.getElementById('profile-result'); |
||||
|
if (resultDiv && resultDiv.innerHTML.trim()) { |
||||
|
resultDiv.classList.add('show'); |
||||
|
|
||||
|
// 3秒后自动隐藏成功消息 |
||||
|
if (resultDiv.classList.contains('success')) { |
||||
|
setTimeout(() => { |
||||
|
resultDiv.classList.remove('show'); |
||||
|
}, 3000); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,403 @@ |
|||||
|
/* 用户信息页面样式 */ |
||||
|
.profile-container { |
||||
|
padding: 2rem; |
||||
|
max-width: 1200px; |
||||
|
margin: 0 auto; |
||||
|
min-height: 100vh; |
||||
|
} |
||||
|
|
||||
|
/* 页面头部 */ |
||||
|
.profile-header { |
||||
|
text-align: center; |
||||
|
margin-bottom: 3rem; |
||||
|
animation: fadeInDown 0.6s ease-out; |
||||
|
} |
||||
|
|
||||
|
.profile-title { |
||||
|
font-size: 2.5rem; |
||||
|
font-weight: 700; |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
-webkit-background-clip: text; |
||||
|
-webkit-text-fill-color: transparent; |
||||
|
background-clip: text; |
||||
|
margin-bottom: 0.5rem; |
||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||
|
} |
||||
|
|
||||
|
.profile-subtitle { |
||||
|
font-size: 1.1rem; |
||||
|
color: #6b7280; |
||||
|
font-weight: 400; |
||||
|
margin: 0; |
||||
|
} |
||||
|
|
||||
|
/* 主要内容区域 */ |
||||
|
.profile-content { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
animation: fadeInUp 0.8s ease-out; |
||||
|
} |
||||
|
|
||||
|
.profile-card { |
||||
|
background: #ffffff; |
||||
|
border-radius: 20px; |
||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); |
||||
|
overflow: hidden; |
||||
|
width: 100%; |
||||
|
max-width: 800px; |
||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.profile-card:hover { |
||||
|
transform: translateY(-5px); |
||||
|
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); |
||||
|
} |
||||
|
|
||||
|
/* 卡片头部 */ |
||||
|
.card-header { |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
padding: 2rem; |
||||
|
color: white; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.avatar-section { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.avatar-preview { |
||||
|
width: 120px; |
||||
|
height: 120px; |
||||
|
border-radius: 50%; |
||||
|
border: 4px solid rgba(255, 255, 255, 0.3); |
||||
|
object-fit: cover; |
||||
|
transition: transform 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.avatar-preview:hover { |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
|
||||
|
.avatar-placeholder { |
||||
|
width: 120px; |
||||
|
height: 120px; |
||||
|
border-radius: 50%; |
||||
|
background: rgba(255, 255, 255, 0.2); |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
border: 4px solid rgba(255, 255, 255, 0.3); |
||||
|
font-size: 3rem; |
||||
|
color: rgba(255, 255, 255, 0.8); |
||||
|
} |
||||
|
|
||||
|
.avatar-info h3 { |
||||
|
font-size: 1.5rem; |
||||
|
font-weight: 600; |
||||
|
margin: 0.5rem 0; |
||||
|
} |
||||
|
|
||||
|
.avatar-info p { |
||||
|
font-size: 0.9rem; |
||||
|
opacity: 0.9; |
||||
|
margin: 0; |
||||
|
} |
||||
|
|
||||
|
/* 表单样式 */ |
||||
|
.profile-form { |
||||
|
padding: 2rem; |
||||
|
} |
||||
|
|
||||
|
.form-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); |
||||
|
gap: 1.5rem; |
||||
|
margin-bottom: 2rem; |
||||
|
} |
||||
|
|
||||
|
.form-group { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.form-group.full-width { |
||||
|
grid-column: 1 / -1; |
||||
|
} |
||||
|
|
||||
|
.form-label { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.5rem; |
||||
|
font-weight: 600; |
||||
|
color: #374151; |
||||
|
margin-bottom: 0.5rem; |
||||
|
font-size: 0.95rem; |
||||
|
} |
||||
|
|
||||
|
.form-label i { |
||||
|
color: #667eea; |
||||
|
width: 16px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.form-input, |
||||
|
.form-textarea, |
||||
|
.form-select { |
||||
|
padding: 0.875rem 1rem; |
||||
|
border: 2px solid #e5e7eb; |
||||
|
border-radius: 12px; |
||||
|
font-size: 1rem; |
||||
|
transition: all 0.3s ease; |
||||
|
background: #ffffff; |
||||
|
color: #374151; |
||||
|
font-family: inherit; |
||||
|
} |
||||
|
|
||||
|
.form-input:focus, |
||||
|
.form-textarea:focus, |
||||
|
.form-select:focus { |
||||
|
outline: none; |
||||
|
border-color: #667eea; |
||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |
||||
|
transform: translateY(-1px); |
||||
|
} |
||||
|
|
||||
|
.form-input:hover, |
||||
|
.form-textarea:hover, |
||||
|
.form-select:hover { |
||||
|
border-color: #d1d5db; |
||||
|
} |
||||
|
|
||||
|
.form-textarea { |
||||
|
resize: vertical; |
||||
|
min-height: 100px; |
||||
|
font-family: inherit; |
||||
|
} |
||||
|
|
||||
|
.form-hint { |
||||
|
font-size: 0.8rem; |
||||
|
color: #6b7280; |
||||
|
margin-top: 0.25rem; |
||||
|
line-height: 1.4; |
||||
|
} |
||||
|
|
||||
|
/* 按钮样式 */ |
||||
|
.form-actions { |
||||
|
display: flex; |
||||
|
gap: 1rem; |
||||
|
justify-content: center; |
||||
|
padding-top: 1rem; |
||||
|
border-top: 1px solid #e5e7eb; |
||||
|
} |
||||
|
|
||||
|
.btn { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 0.5rem; |
||||
|
padding: 0.875rem 2rem; |
||||
|
border: none; |
||||
|
border-radius: 12px; |
||||
|
font-size: 1rem; |
||||
|
font-weight: 600; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s ease; |
||||
|
text-decoration: none; |
||||
|
font-family: inherit; |
||||
|
min-width: 140px; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.btn-primary { |
||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
|
color: white; |
||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); |
||||
|
} |
||||
|
|
||||
|
.btn-primary:hover { |
||||
|
transform: translateY(-2px); |
||||
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); |
||||
|
} |
||||
|
|
||||
|
.btn-secondary { |
||||
|
background: #f3f4f6; |
||||
|
color: #374151; |
||||
|
border: 2px solid #e5e7eb; |
||||
|
} |
||||
|
|
||||
|
.btn-secondary:hover { |
||||
|
background: #e5e7eb; |
||||
|
transform: translateY(-2px); |
||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); |
||||
|
} |
||||
|
|
||||
|
/* 结果提示 */ |
||||
|
.profile-result { |
||||
|
margin-top: 1rem; |
||||
|
padding: 1rem; |
||||
|
border-radius: 12px; |
||||
|
text-align: center; |
||||
|
font-weight: 500; |
||||
|
opacity: 0; |
||||
|
transform: translateY(-10px); |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.profile-result.show { |
||||
|
opacity: 1; |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
|
||||
|
.profile-result.success { |
||||
|
background: #d1fae5; |
||||
|
color: #065f46; |
||||
|
border: 1px solid #a7f3d0; |
||||
|
} |
||||
|
|
||||
|
.profile-result.error { |
||||
|
background: #fee2e2; |
||||
|
color: #991b1b; |
||||
|
border: 1px solid #fca5a5; |
||||
|
} |
||||
|
|
||||
|
/* 动画效果 */ |
||||
|
@keyframes fadeInDown { |
||||
|
from { |
||||
|
opacity: 0; |
||||
|
transform: translateY(-30px); |
||||
|
} |
||||
|
to { |
||||
|
opacity: 1; |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes fadeInUp { |
||||
|
from { |
||||
|
opacity: 0; |
||||
|
transform: translateY(30px); |
||||
|
} |
||||
|
to { |
||||
|
opacity: 1; |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes pulse { |
||||
|
0%, 100% { |
||||
|
transform: scale(1); |
||||
|
} |
||||
|
50% { |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 响应式设计 */ |
||||
|
@media (max-width: 768px) { |
||||
|
.profile-container { |
||||
|
padding: 1rem; |
||||
|
} |
||||
|
|
||||
|
.profile-title { |
||||
|
font-size: 2rem; |
||||
|
} |
||||
|
|
||||
|
.form-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.form-actions { |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.btn { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.card-header { |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.profile-form { |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 480px) { |
||||
|
.profile-container { |
||||
|
padding: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.profile-title { |
||||
|
font-size: 1.75rem; |
||||
|
} |
||||
|
|
||||
|
.avatar-preview, |
||||
|
.avatar-placeholder { |
||||
|
width: 80px; |
||||
|
height: 80px; |
||||
|
} |
||||
|
|
||||
|
.avatar-placeholder { |
||||
|
font-size: 2rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 加载状态 */ |
||||
|
.btn.loading { |
||||
|
position: relative; |
||||
|
color: transparent; |
||||
|
} |
||||
|
|
||||
|
.btn.loading::after { |
||||
|
content: ''; |
||||
|
position: absolute; |
||||
|
width: 20px; |
||||
|
height: 20px; |
||||
|
top: 50%; |
||||
|
left: 50%; |
||||
|
margin-left: -10px; |
||||
|
margin-top: -10px; |
||||
|
border: 2px solid transparent; |
||||
|
border-top-color: currentColor; |
||||
|
border-radius: 50%; |
||||
|
animation: spin 1s linear infinite; |
||||
|
} |
||||
|
|
||||
|
@keyframes spin { |
||||
|
0% { transform: rotate(0deg); } |
||||
|
100% { transform: rotate(360deg); } |
||||
|
} |
||||
|
|
||||
|
/* 输入验证状态 */ |
||||
|
.form-input.error, |
||||
|
.form-textarea.error, |
||||
|
.form-select.error { |
||||
|
border-color: #ef4444; |
||||
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); |
||||
|
} |
||||
|
|
||||
|
.form-input.success, |
||||
|
.form-textarea.success, |
||||
|
.form-select.success { |
||||
|
border-color: #10b981; |
||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); |
||||
|
} |
||||
|
|
||||
|
/* 头像预览更新动画 */ |
||||
|
.avatar-preview.updated { |
||||
|
animation: pulse 0.6s ease-in-out; |
||||
|
} |
||||
|
|
||||
|
/* 表单组聚焦效果 */ |
||||
|
.form-group:focus-within .form-label { |
||||
|
color: #667eea; |
||||
|
} |
||||
|
|
||||
|
.form-group:focus-within .form-label i { |
||||
|
color: #764ba2; |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
- let confirmPasswordLabel = "确认密码" |
||||
|
- let confirmPasswordPlaceholder = "请输入确认密码" |
||||
|
- let confirmPasswordUrl = "/register/validate/confirmPassword" |
||||
|
div(hx-target="this" hx-swap="outerHTML") |
||||
|
div(class="relative") |
||||
|
label.block.text-sm.font-medium.text-gray-700.mb-2(for="confirmPassword") #{confirmPasswordLabel} |
||||
|
input(type="password" id="confirmPassword" value=value name="confirmPassword" placeholder=confirmPasswordPlaceholder hx-indicator="#ind" hx-post=confirmPasswordUrl hx-trigger="blur delay:500ms" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out" + (error ? ' border-red-500 focus:ring-red-500' : '')) |
||||
|
div(id="ind" class="htmx-indicator absolute right-3 top-9 transform -translate-y-1/2") |
||||
|
div(class="animate-spin h-5 w-5 border-2 border-gray-300 border-t-blue-500 rounded-full") |
||||
|
if error |
||||
|
div(class="error-message text-red-500 text-sm mt-2 flex items-center") |
||||
|
svg.w-4.h-4.mr-1(xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor") |
||||
|
path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z") |
||||
|
| #{error} |
||||
@ -0,0 +1,6 @@ |
|||||
|
- let nicknameLabel = "昵称" |
||||
|
- let nicknamePlaceholder = "请输入昵称(可选,默认与用户名相同)" |
||||
|
div(hx-target="this" hx-swap="outerHTML") |
||||
|
div(class="relative") |
||||
|
label.block.text-sm.font-medium.text-gray-700.mb-2(for="nickname") #{nicknameLabel} |
||||
|
input(type="text" id="nickname" value=value name="nickname" placeholder=nicknamePlaceholder class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out") |
||||
@ -0,0 +1,14 @@ |
|||||
|
- let pwdLabel = "密码" |
||||
|
- let pwdPlaceholder = "请输入密码" |
||||
|
- let pwdUrl = "/register/validate/password" |
||||
|
div(hx-target="this" hx-swap="outerHTML") |
||||
|
div(class="relative") |
||||
|
label.block.text-sm.font-medium.text-gray-700.mb-2(for="password") #{pwdLabel} |
||||
|
input(type="password" id="password" value=value name="password" placeholder=pwdPlaceholder hx-indicator="#ind" hx-post=pwdUrl hx-trigger="blur delay:500ms" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out" + (error ? ' border-red-500 focus:ring-red-500' : '')) |
||||
|
div(id="ind" class="htmx-indicator absolute right-3 top-9 transform -translate-y-1/2") |
||||
|
div(class="animate-spin h-5 w-5 border-2 border-gray-300 border-t-blue-500 rounded-full") |
||||
|
if error |
||||
|
div(class="error-message text-red-500 text-sm mt-2 flex items-center") |
||||
|
svg.w-4.h-4.mr-1(xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor") |
||||
|
path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z") |
||||
|
| #{error} |
||||
@ -0,0 +1,14 @@ |
|||||
|
- let label = "用户名" |
||||
|
- let placeholder = "请输入用户名" |
||||
|
- let url = "/register/validate/username" |
||||
|
div(hx-target="this" hx-swap="outerHTML") |
||||
|
div(class="relative") |
||||
|
label.block.text-sm.font-medium.text-gray-700.mb-2(for="username") #{label} |
||||
|
input(type="text" id="username" value=value name="username" placeholder=placeholder hx-indicator="#ind" hx-post=url hx-trigger="blur delay:500ms" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out" + (error ? ' border-red-500 focus:ring-red-500' : '')) |
||||
|
div(id="ind" class="htmx-indicator absolute right-3 top-9 transform -translate-y-1/2") |
||||
|
div(class="animate-spin h-5 w-5 border-2 border-gray-300 border-t-blue-500 rounded-full") |
||||
|
if error |
||||
|
div(class="error-message text-red-500 text-sm mt-2 flex items-center") |
||||
|
svg.w-4.h-4.mr-1(xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor") |
||||
|
path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z") |
||||
|
| #{error} |
||||
@ -0,0 +1,67 @@ |
|||||
|
extends /layouts/root.pug |
||||
|
|
||||
|
block $$head |
||||
|
style |
||||
|
include ./style.css |
||||
|
|
||||
|
|
||||
|
block $$content |
||||
|
.page-layout.bg-gray-50 |
||||
|
navbar |
||||
|
//- .placeholder(class="h-[75px] w-full opacity-0") |
||||
|
.fixed.top-0.left-0.right-0.z-10(class="h-[75px]") |
||||
|
.container.h-full |
||||
|
a.h-full.flex.items-center.float-left.text-2xl.font-bold(href="/") 烟霞渡 |
||||
|
canvas#background.absolute.block.top-0.left-0.z-0 |
||||
|
.h-full.relative(class="sm:px-6 lg:px-8") |
||||
|
.container.h-full.flex.items-center |
||||
|
.flex-1.h-full.flex.items-center.justify-center |
||||
|
h1.text-4xl.font-bold#chars |
||||
|
div 烟霞渡! |
||||
|
.mt-5 欢迎您的到来 |
||||
|
.flex-1.px-4.max-w-md |
||||
|
.w-full.space-y-8 |
||||
|
.py-8.px-4(class="sm:px-10") |
||||
|
.text-center.mb-8 |
||||
|
form.space-y-6(hx-post="/register") |
||||
|
include _ui/username.pug |
||||
|
include _ui/nickname.pug |
||||
|
include _ui/password.pug |
||||
|
include _ui/confirmPassword.pug |
||||
|
div |
||||
|
button.group.relative.w-full.flex.justify-center.py-3.px-4.border.border-transparent.text-sm.font-medium.rounded-md.text-white.bg-blue-600(type="submit" class="hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-150 ease-in-out") |
||||
|
span.absolute.left-0.inset-y-0.flex.items-center.pl-3 |
||||
|
span 注册 |
||||
|
.text-center |
||||
|
p.text-sm.text-gray-600 |
||||
|
| 已有账户? |
||||
|
a.font-medium.text-blue-600(href="/login" class="hover:text-blue-500") 立即登录 |
||||
|
|
||||
|
block $$scripts |
||||
|
+js("https://cdnjs.cloudflare.com/ajax/libs/particlesjs/2.2.2/particles.min.js") |
||||
|
+js("https://unpkg.co/gsap@3/dist/gsap.min.js") |
||||
|
+js("https://assets.codepen.io/16327/SplitText3-beta.min.js?b=43") |
||||
|
script. |
||||
|
Particles.init({ |
||||
|
selector: '#background', |
||||
|
maxParticles: 350, |
||||
|
}); |
||||
|
gsap.registerPlugin(SplitText); |
||||
|
let split, animation; |
||||
|
split = SplitText.create("#chars", {type:"chars"}); |
||||
|
animation && animation.revert(); |
||||
|
animation = gsap.from(split.chars, { |
||||
|
x: 150, |
||||
|
opacity: 0, |
||||
|
duration: 1.7, |
||||
|
ease: "power4", |
||||
|
stagger: 0.04 |
||||
|
}) |
||||
|
|
||||
|
document.addEventListener('htmx:error', function(evt) { |
||||
|
if(evt.detail.elt instanceof HTMLElement) { |
||||
|
if(evt.detail.elt.tagName === 'FORM' && evt.detail.xhr) { |
||||
|
window.alert(evt.detail.xhr.response || '请求失败') |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
@ -0,0 +1,22 @@ |
|||||
|
.page-layout { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
width: 100%; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.container { |
||||
|
max-width: 1226px; |
||||
|
margin-right: auto; |
||||
|
margin-left: auto; |
||||
|
/* padding-left: 20px; |
||||
|
padding-right: 20px; */ |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 640px) { |
||||
|
.container { |
||||
|
padding-left: 10px; |
||||
|
padding-right: 10px; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue