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 { logger } from "../../logger.js" |
|||
import db from "@/db" |
|||
import { logger } from "@/logger.js" |
|||
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 db from "../index.js" |
|||
import BaseModel from "@/base/BaseModel.js" |
|||
import db from "@/db" |
|||
|
|||
class SiteConfigModel extends BaseModel { |
|||
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) |
|||
if extranl || url.startsWith('http') || url.startsWith('//') |
|||
link(rel="stylesheet" type="text/css" href=url) |
|||
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) |
|||
if extranl || url.startsWith('http') || url.startsWith('//') |
|||
script(type="text/javascript" src=url) |
|||
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) |
|||
//- 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