Browse Source

删除多个不再使用的文档和模型文件,新增基础模型和用户、站点配置相关的控制器与服务,优化数据库结构和路由配置,更新用户信息页面和注册页面的样式与功能。

pure
谢亚昕 3 months ago
parent
commit
71f7b00211
  1. 355
      .qoder/quests/admin-backend-implementation.md
  2. 617
      .qoder/quests/db-module-check-and-optimization-1757490337.md
  3. BIN
      database/db.sqlite3
  4. BIN
      database/db.sqlite3-shm
  5. BIN
      database/db.sqlite3-wal
  6. BIN
      database/development.sqlite3-shm
  7. BIN
      database/development.sqlite3-wal
  8. 4
      src/base/BaseModel.js
  9. 190
      src/db/docs/ArticleModel.md
  10. 194
      src/db/docs/BookmarkModel.md
  11. 252
      src/db/docs/README.md
  12. 246
      src/db/docs/SiteConfigModel.md
  13. 158
      src/db/docs/UserModel.md
  14. 20
      src/db/migrations/20250616065041_create_users_table.mjs
  15. 8
      src/db/migrations/20250621013128_site_config.mjs
  16. 26
      src/db/migrations/20250830014825_create_articles_table.mjs
  17. 25
      src/db/migrations/20250830015422_create_bookmarks_table.mjs
  18. 60
      src/db/migrations/20250830020000_add_article_fields.mjs
  19. 25
      src/db/migrations/20250901000000_add_profile_fields.mjs
  20. 31
      src/db/migrations/20250909000000_create_contacts_table.mjs
  21. 146
      src/db/migrations/20250910000001_add_performance_indexes.mjs
  22. 548
      src/db/models/ArticleModel.js
  23. 158
      src/db/models/BookmarkModel.js
  24. 132
      src/db/models/ContactModel.js
  25. 8
      src/db/seeds/20250621013324_site_config_seed.mjs
  26. 77
      src/db/seeds/20250830020000_articles_seed.mjs
  27. 1
      src/middlewares/install.js
  28. 43
      src/modules/Admin/controller/index.js
  29. 0
      src/modules/Auth/controller/login.js
  30. 101
      src/modules/Auth/controller/register.js
  31. 0
      src/modules/Auth/controller/user.js
  32. 4
      src/modules/Auth/model/user.js
  33. 12
      src/modules/Auth/services/index.js
  34. 4
      src/modules/SiteConfig/model/site-config.js
  35. 2
      src/modules/SiteConfig/services/index.js
  36. 11
      src/views/helper/utils.pug
  37. 3
      src/views/htmx/footer/index.pug
  38. 33
      src/views/layouts/admin.pug
  39. 8
      src/views/page/admin/index/index.pug
  40. 0
      src/views/page/admin/index/style.css
  41. 398
      src/views/page/admin/profile/index.pug
  42. 403
      src/views/page/admin/profile/style.css
  43. 8
      src/views/page/auth/no-auth.pug
  44. 2
      src/views/page/index/index.pug
  45. 14
      src/views/page/register/_ui/confirmPassword.pug
  46. 6
      src/views/page/register/_ui/nickname.pug
  47. 14
      src/views/page/register/_ui/password.pug
  48. 14
      src/views/page/register/_ui/username.pug
  49. 67
      src/views/page/register/index.pug
  50. 22
      src/views/page/register/style.css

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

@ -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攻击防护测试
- 数据验证测试

617
.qoder/quests/db-module-check-and-optimization-1757490337.md

@ -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. 生产环境验证

BIN
database/db.sqlite3

Binary file not shown.

BIN
database/db.sqlite3-shm

Binary file not shown.

BIN
database/db.sqlite3-wal

Binary file not shown.

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

4
src/db/models/BaseModel.js → src/base/BaseModel.js

@ -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"
/**

190
src/db/docs/ArticleModel.md

@ -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
```

194
src/db/docs/BookmarkModel.md

@ -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. **权限控制**: 实现适当的访问控制机制

252
src/db/docs/README.md

@ -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 许可证。

246
src/db/docs/SiteConfigModel.md

@ -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. **配置缓存**: 实现配置缓存机制,减少数据库查询

158
src/db/docs/UserModel.md

@ -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注入
### 扩展建议
可以考虑添加以下功能:
- 用户状态管理(激活/禁用)
- 密码重置功能
- 用户头像管理
- 用户偏好设置
- 登录历史记录
- 用户组管理

20
src/db/migrations/20250616065041_create_users_table.mjs

@ -5,14 +5,18 @@
export const up = async knex => {
return knex.schema.createTable("users", function (table) {
table.increments("id").primary() // 自增主键
table.string("username", 100).notNullable() // 字符串字段(最大长度100)
table.string("email", 100).unique() // 唯一邮箱
table.string("password", 100).notNullable() // 密码
table.string("role", 100).notNullable()
table.string("phone", 100)
table.integer("age").unsigned() // 无符号整数
table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间
table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间
table.string("username", 100).notNullable().unique().comment("用户名") // 字符串字段(最大长度100)
table.string("nickname", 100).comment("昵称") // 字符串字段(最大长度100)
table.string("bio").comment("个人简介")
table.string("avatar", 500).comment("头像") // 字符串字段(最大长度100)
table.string("email", 100).unique().comment("邮箱") // 唯一邮箱
table.string("password", 100).notNullable().comment("密码") // 密码
table.string("role", 100).notNullable().defaultTo("user").comment("角色 user, admin") // 角色 user, admin
table.string("phone", 100).comment("电话号码")
table.string("status", 20).defaultTo("inactive").comment("用户状态 inactive, active") // 用户状态 inactive, active
table.integer("age").unsigned().comment("年龄") // 无符号整数
table.timestamp("created_at").defaultTo(knex.fn.now()).comment("创建时间") // 创建时间
table.timestamp("updated_at").defaultTo(knex.fn.now()).comment("更新时间") // 更新时间
})
}

8
src/db/migrations/20250621013128_site_config.mjs

@ -5,10 +5,10 @@
export const up = async knex => {
return knex.schema.createTable("site_config", function (table) {
table.increments("id").primary() // 自增主键
table.string("key", 100).notNullable().unique() // 配置项key,唯一
table.text("value").notNullable() // 配置项value
table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间
table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间
table.string("key", 100).notNullable().unique().comment("配置项key,唯一") // 配置项key,唯一
table.text("value").notNullable().comment("配置项value") // 配置项value
table.timestamp("created_at").defaultTo(knex.fn.now()).comment("创建时间") // 创建时间
table.timestamp("updated_at").defaultTo(knex.fn.now()).comment("更新时间") // 更新时间
})
}

26
src/db/migrations/20250830014825_create_articles_table.mjs

@ -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")
}

25
src/db/migrations/20250830015422_create_bookmarks_table.mjs

@ -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")
}

60
src/db/migrations/20250830020000_add_article_fields.mjs

@ -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"])
})
}

25
src/db/migrations/20250901000000_add_profile_fields.mjs

@ -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")
})
}

31
src/db/migrations/20250909000000_create_contacts_table.mjs

@ -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');
};

146
src/db/migrations/20250910000001_add_performance_indexes.mjs

@ -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('所有性能优化索引移除完成!')
}

548
src/db/models/ArticleModel.js

@ -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 }

158
src/db/models/BookmarkModel.js

@ -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 }

132
src/db/models/ContactModel.js

@ -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 }

8
src/db/seeds/20250621013324_site_config_seed.mjs

@ -4,12 +4,14 @@ export const seed = async (knex) => {
// 插入常用站点配置项
await knex('site_config').insert([
{ key: 'site_title', value: '罗非鱼的秘密' },
{ key: 'site_title', value: '烟霞渡' },
{ key: 'site_author', value: '罗非鱼' },
{ key: 'site_author_avatar', value: 'https://alist.xieyaxin.top/p/%E6%B8%B8%E5%AE%A2%E6%96%87%E4%BB%B6/%E5%85%AC%E5%85%B1%E4%BF%A1%E6%81%AF/avatar.jpg' },
{ key: 'site_description', value: '一屋很小,却也很大' },
{ key: 'site_description', value: '如梦如幻,如烟如霞,似真似幻,似梦似醒' },
{ key: 'site_logo', value: '/static/logo.png' },
{ key: 'site_bg', value: '/static/bg.jpg' },
{ key: 'keywords', value: 'blog' }
{ key: 'site_favicon', value: '/static/bg.jpg' },
{ key: 'keywords', value: 'blog' },
{ key: 'site_base', value: '/' }
]);
};

77
src/db/seeds/20250830020000_articles_seed.mjs

@ -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![图片描述](图片URL)\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!")
}

1
src/middlewares/install.js

@ -111,7 +111,6 @@ export default async app => {
// 提供全局数据
app.use(async (ctx, next) => {
ctx.state.siteConfig = await SiteConfigService.getAll()
ctx.state.$config = config
return await next()
})
// 错误处理,主要处理运行中抛出的错误

43
src/modules/Admin/controller/index.js

@ -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
src/modules/Auth/controller/index.js → src/modules/Auth/controller/login.js

101
src/modules/Auth/controller/register.js

@ -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", "/")
}
}

0
database/.gitkeep → src/modules/Auth/controller/user.js

4
src/db/models/UserModel.js → src/modules/Auth/model/user.js

@ -1,4 +1,4 @@
import BaseModel from "./BaseModel.js"
import BaseModel from "@/base/BaseModel.js"
class UserModel extends BaseModel {
/**
@ -55,6 +55,8 @@ class UserModel extends BaseModel {
// 重写update方法添加验证
async update(id, data) {
console.log(id, data);
// 验证唯一性(排除当前用户)
if (data.username) {
const existingUser = await this.findFirst({ username: data.username })

12
src/modules/Auth/services/index.js

@ -1,6 +1,6 @@
import UserModel from "@/db/models/UserModel.js"
import UserModel from "../model/user"
import CommonError from "@/utils/error/CommonError.js"
import { comparePassword } from "@/utils/bcrypt.js"
import { comparePassword, hashPassword } from "@/utils/bcrypt.js"
import { JWT_SECRET } from "@/middlewares/Auth/index.js"
import jwt from "jsonwebtoken"
@ -9,6 +9,10 @@ import jwt from "jsonwebtoken"
* 提供认证相关的业务逻辑
*/
export class AuthService {
static async findById(id) {
return UserModel.getInstance().findById(id)
}
// 注册新用户
static async register(data) {
try {
@ -83,6 +87,10 @@ export class AuthService {
throw new CommonError(`登录失败: ${error.message}`)
}
}
static async update(id, data) {
return UserModel.getInstance().update(id, data)
}
}
export default AuthService

4
src/db/models/SiteConfigModel.js → src/modules/SiteConfig/model/site-config.js

@ -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() {

2
src/modules/SiteConfig/services/index.js

@ -1,4 +1,4 @@
import SiteConfigModel from "@/db/models/SiteConfigModel.js"
import SiteConfigModel from "../model/site-config"
import { logger } from "@/logger.js"
/**

11
src/views/helper/utils.pug

@ -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"}

3
src/views/htmx/footer/index.pug

@ -6,7 +6,8 @@ footer.footer.shadow
.footer-main(class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8")
.footer-section
h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") #{siteConfig.site_title}
p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。<br>岁月催人老,用真情对待自己。
//- p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。<br>岁月催人老,用真情对待自己。
p.footer-desc(class="text-gray-600 text-sm leading-relaxed" style="text-wrap: balance;") #{siteConfig.site_description}
.footer-section
h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 快速链接

33
src/views/layouts/admin.pug

@ -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

8
src/views/page/admin/index/index.pug

@ -0,0 +1,8 @@
extends /layouts/admin.pug
block Head
style
include ./style.css
block Content
div

0
.qoder/quests/db-module-check-and-optimization.md → src/views/page/admin/index/style.css

398
src/views/page/admin/profile/index.pug

@ -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);
}
}
});
});

403
src/views/page/admin/profile/style.css

@ -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;
}

8
src/views/page/auth/no-auth.pug

@ -1,6 +1,6 @@
extends /layouts/empty.pug
extends /layouts/root.pug
block pageContent
block $$content
.no-auth-container
.no-auth-icon
i.fa.fa-lock
@ -8,7 +8,7 @@ block pageContent
p 您没有权限访问此页面,请先登录或联系管理员。
a.btn(href='/login') 去登录
block pageHead
block $$head
style.
.no-auth-container {
display: flex;
@ -48,7 +48,7 @@ block pageHead
color: #fff200;
}
//- block pageScripts
//- block $$scripts
//- script.
//- const curUrl = URL.parse(location.href).searchParams.get("from")
//- fetch(curUrl,{redirect: 'error'}).then(res=>location.href=curUrl).catch(e=>console.log(e))

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

@ -28,7 +28,7 @@ block $$content
a.flex.items-center.px-5(href="/login") 登录
else
a.flex.items-center.px-5.cursor-pointer(hx-post="/logout") 退出
a.flex.items-center.px-5.cursor-pointer 后台
a.flex.items-center.px-5.cursor-pointer(href="/admin") 后台
a.flex.items-center.px-5(href="/profile") 欢迎您,#{user.username}
canvas#background.absolute.block.top-0.left-0.z-0
.min-h-screen.relative

14
src/views/page/register/_ui/confirmPassword.pug

@ -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}

6
src/views/page/register/_ui/nickname.pug

@ -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")

14
src/views/page/register/_ui/password.pug

@ -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}

14
src/views/page/register/_ui/username.pug

@ -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}

67
src/views/page/register/index.pug

@ -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 || '请求失败')
}
}
});

22
src/views/page/register/style.css

@ -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…
Cancel
Save