diff --git a/.qoder/quests/admin-backend-implementation.md b/.qoder/quests/admin-backend-implementation.md deleted file mode 100644 index 42e3811..0000000 --- a/.qoder/quests/admin-backend-implementation.md +++ /dev/null @@ -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攻击防护测试 -- 数据验证测试 \ No newline at end of file diff --git a/.qoder/quests/db-module-check-and-optimization-1757490337.md b/.qoder/quests/db-module-check-and-optimization-1757490337.md deleted file mode 100644 index d81e3f6..0000000 --- a/.qoder/quests/db-module-check-and-optimization-1757490337.md +++ /dev/null @@ -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. 生产环境验证 \ No newline at end of file diff --git a/database/db.sqlite3 b/database/db.sqlite3 index 3f03792..f721384 100644 Binary files a/database/db.sqlite3 and b/database/db.sqlite3 differ diff --git a/database/db.sqlite3-shm b/database/db.sqlite3-shm index fe9ac28..d33f367 100644 Binary files a/database/db.sqlite3-shm and b/database/db.sqlite3-shm differ diff --git a/database/db.sqlite3-wal b/database/db.sqlite3-wal index e69de29..54a877e 100644 Binary files a/database/db.sqlite3-wal and b/database/db.sqlite3-wal differ diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 95164b4..10e6078 100644 Binary files a/database/development.sqlite3-shm and b/database/development.sqlite3-shm differ diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal index 6368271..8455649 100644 Binary files a/database/development.sqlite3-wal and b/database/development.sqlite3-wal differ diff --git a/src/db/models/BaseModel.js b/src/base/BaseModel.js similarity index 99% rename from src/db/models/BaseModel.js rename to src/base/BaseModel.js index c15e39f..e79743b 100644 --- a/src/db/models/BaseModel.js +++ b/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" /** diff --git a/src/db/docs/ArticleModel.md b/src/db/docs/ArticleModel.md deleted file mode 100644 index c7e3d93..0000000 --- a/src/db/docs/ArticleModel.md +++ /dev/null @@ -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 -``` diff --git a/src/db/docs/BookmarkModel.md b/src/db/docs/BookmarkModel.md deleted file mode 100644 index 273129b..0000000 --- a/src/db/docs/BookmarkModel.md +++ /dev/null @@ -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. **权限控制**: 实现适当的访问控制机制 diff --git a/src/db/docs/README.md b/src/db/docs/README.md deleted file mode 100644 index 16a5aec..0000000 --- a/src/db/docs/README.md +++ /dev/null @@ -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 许可证。 diff --git a/src/db/docs/SiteConfigModel.md b/src/db/docs/SiteConfigModel.md deleted file mode 100644 index 64b03d5..0000000 --- a/src/db/docs/SiteConfigModel.md +++ /dev/null @@ -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. **配置缓存**: 实现配置缓存机制,减少数据库查询 diff --git a/src/db/docs/UserModel.md b/src/db/docs/UserModel.md deleted file mode 100644 index c8bb373..0000000 --- a/src/db/docs/UserModel.md +++ /dev/null @@ -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注入 - -### 扩展建议 - -可以考虑添加以下功能: -- 用户状态管理(激活/禁用) -- 密码重置功能 -- 用户头像管理 -- 用户偏好设置 -- 登录历史记录 -- 用户组管理 diff --git a/src/db/migrations/20250616065041_create_users_table.mjs b/src/db/migrations/20250616065041_create_users_table.mjs index a431899..a658d69 100644 --- a/src/db/migrations/20250616065041_create_users_table.mjs +++ b/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("更新时间") // 更新时间 }) } diff --git a/src/db/migrations/20250621013128_site_config.mjs b/src/db/migrations/20250621013128_site_config.mjs index 87e998b..52d475f 100644 --- a/src/db/migrations/20250621013128_site_config.mjs +++ b/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("更新时间") // 更新时间 }) } diff --git a/src/db/migrations/20250830014825_create_articles_table.mjs b/src/db/migrations/20250830014825_create_articles_table.mjs deleted file mode 100644 index 7dcf1b9..0000000 --- a/src/db/migrations/20250830014825_create_articles_table.mjs +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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 } - */ -export const down = async knex => { - return knex.schema.dropTable("articles") -} diff --git a/src/db/migrations/20250830015422_create_bookmarks_table.mjs b/src/db/migrations/20250830015422_create_bookmarks_table.mjs deleted file mode 100644 index 52ff3cc..0000000 --- a/src/db/migrations/20250830015422_create_bookmarks_table.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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 } - */ -export const down = async knex => { - return knex.schema.dropTable("bookmarks") -} diff --git a/src/db/migrations/20250830020000_add_article_fields.mjs b/src/db/migrations/20250830020000_add_article_fields.mjs deleted file mode 100644 index 2775c57..0000000 --- a/src/db/migrations/20250830020000_add_article_fields.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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 } - */ -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"]) - }) -} diff --git a/src/db/migrations/20250901000000_add_profile_fields.mjs b/src/db/migrations/20250901000000_add_profile_fields.mjs deleted file mode 100644 index 3f27c22..0000000 --- a/src/db/migrations/20250901000000_add_profile_fields.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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 } - */ -export const down = async knex => { - return knex.schema.alterTable("users", function (table) { - table.dropColumn("name") - table.dropColumn("bio") - table.dropColumn("avatar") - table.dropColumn("status") - }) -} diff --git a/src/db/migrations/20250909000000_create_contacts_table.mjs b/src/db/migrations/20250909000000_create_contacts_table.mjs deleted file mode 100644 index a3a6198..0000000 --- a/src/db/migrations/20250909000000_create_contacts_table.mjs +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 联系信息表迁移文件 - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = function(knex) { - return knex.schema.createTable('contacts', function (table) { - table.increments('id').primary(); - table.string('name', 100).notNullable().comment('联系人姓名'); - table.string('email', 255).notNullable().comment('邮箱地址'); - table.string('subject', 255).notNullable().comment('主题'); - table.text('message').notNullable().comment('留言内容'); - table.string('ip_address', 45).nullable().comment('IP地址'); - table.text('user_agent').nullable().comment('浏览器信息'); - table.string('status', 20).defaultTo('unread').comment('处理状态: unread, read, replied'); - table.timestamps(true, true); // created_at, updated_at - - // 添加索引 - table.index('status', 'idx_contacts_status'); - table.index('created_at', 'idx_contacts_created_at'); - table.index('email', 'idx_contacts_email'); - }); -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = function(knex) { - return knex.schema.dropTable('contacts'); -}; \ No newline at end of file diff --git a/src/db/migrations/20250910000001_add_performance_indexes.mjs b/src/db/migrations/20250910000001_add_performance_indexes.mjs deleted file mode 100644 index 0341d82..0000000 --- a/src/db/migrations/20250910000001_add_performance_indexes.mjs +++ /dev/null @@ -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('所有性能优化索引移除完成!') -} \ No newline at end of file diff --git a/src/db/models/ArticleModel.js b/src/db/models/ArticleModel.js deleted file mode 100644 index c034aca..0000000 --- a/src/db/models/ArticleModel.js +++ /dev/null @@ -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 } diff --git a/src/db/models/BookmarkModel.js b/src/db/models/BookmarkModel.js deleted file mode 100644 index 9741418..0000000 --- a/src/db/models/BookmarkModel.js +++ /dev/null @@ -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 } - - diff --git a/src/db/models/ContactModel.js b/src/db/models/ContactModel.js deleted file mode 100644 index 326aec4..0000000 --- a/src/db/models/ContactModel.js +++ /dev/null @@ -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 } \ No newline at end of file diff --git a/src/db/seeds/20250621013324_site_config_seed.mjs b/src/db/seeds/20250621013324_site_config_seed.mjs index ec3c7c5..a1e9c3a 100644 --- a/src/db/seeds/20250621013324_site_config_seed.mjs +++ b/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: '/' } ]); }; diff --git a/src/db/seeds/20250830020000_articles_seed.mjs b/src/db/seeds/20250830020000_articles_seed.mjs deleted file mode 100644 index 0dea864..0000000 --- a/src/db/seeds/20250830020000_articles_seed.mjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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!") -} diff --git a/src/middlewares/install.js b/src/middlewares/install.js index 49e3d5b..cc29264 100644 --- a/src/middlewares/install.js +++ b/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() }) // 错误处理,主要处理运行中抛出的错误 diff --git a/src/modules/Admin/controller/index.js b/src/modules/Admin/controller/index.js new file mode 100644 index 0000000..5e263df --- /dev/null +++ b/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") + } +} diff --git a/src/modules/Auth/controller/index.js b/src/modules/Auth/controller/login.js similarity index 100% rename from src/modules/Auth/controller/index.js rename to src/modules/Auth/controller/login.js diff --git a/src/modules/Auth/controller/register.js b/src/modules/Auth/controller/register.js new file mode 100644 index 0000000..7fc408f --- /dev/null +++ b/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", "/") + } +} diff --git a/database/.gitkeep b/src/modules/Auth/controller/user.js similarity index 100% rename from database/.gitkeep rename to src/modules/Auth/controller/user.js diff --git a/src/db/models/UserModel.js b/src/modules/Auth/model/user.js similarity index 96% rename from src/db/models/UserModel.js rename to src/modules/Auth/model/user.js index 5e467e0..3c6af04 100644 --- a/src/db/models/UserModel.js +++ b/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 }) diff --git a/src/modules/Auth/services/index.js b/src/modules/Auth/services/index.js index 063d413..378d732 100644 --- a/src/modules/Auth/services/index.js +++ b/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 diff --git a/src/db/models/SiteConfigModel.js b/src/modules/SiteConfig/model/site-config.js similarity index 95% rename from src/db/models/SiteConfigModel.js rename to src/modules/SiteConfig/model/site-config.js index d8340ae..289523c 100644 --- a/src/db/models/SiteConfigModel.js +++ b/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() { diff --git a/src/modules/SiteConfig/services/index.js b/src/modules/SiteConfig/services/index.js index 0d414a2..ea402f9 100644 --- a/src/modules/SiteConfig/services/index.js +++ b/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" /** diff --git a/src/views/helper/utils.pug b/src/views/helper/utils.pug index 28cebef..be3fe52 100644 --- a/src/views/helper/utils.pug +++ b/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"} diff --git a/src/views/htmx/footer/index.pug b/src/views/htmx/footer/index.pug index a1ad3bd..59d59b9 100644 --- a/src/views/htmx/footer/index.pug +++ b/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") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 + //- p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 + 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") 快速链接 diff --git a/src/views/layouts/admin.pug b/src/views/layouts/admin.pug new file mode 100644 index 0000000..97c9e01 --- /dev/null +++ b/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 + \ No newline at end of file diff --git a/src/views/page/admin/index/index.pug b/src/views/page/admin/index/index.pug new file mode 100644 index 0000000..e15bbce --- /dev/null +++ b/src/views/page/admin/index/index.pug @@ -0,0 +1,8 @@ +extends /layouts/admin.pug + +block Head + style + include ./style.css + +block Content + div \ No newline at end of file diff --git a/.qoder/quests/db-module-check-and-optimization.md b/src/views/page/admin/index/style.css similarity index 100% rename from .qoder/quests/db-module-check-and-optimization.md rename to src/views/page/admin/index/style.css diff --git a/src/views/page/admin/profile/index.pug b/src/views/page/admin/profile/index.pug new file mode 100644 index 0000000..917a531 --- /dev/null +++ b/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); + } + } + }); + }); \ No newline at end of file diff --git a/src/views/page/admin/profile/style.css b/src/views/page/admin/profile/style.css new file mode 100644 index 0000000..13629ef --- /dev/null +++ b/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; +} diff --git a/src/views/page/auth/no-auth.pug b/src/views/page/auth/no-auth.pug index d578636..28b776a 100644 --- a/src/views/page/auth/no-auth.pug +++ b/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)) \ No newline at end of file diff --git a/src/views/page/index/index.pug b/src/views/page/index/index.pug index 221f557..9fb4993 100644 --- a/src/views/page/index/index.pug +++ b/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 diff --git a/src/views/page/register/_ui/confirmPassword.pug b/src/views/page/register/_ui/confirmPassword.pug new file mode 100644 index 0000000..e882373 --- /dev/null +++ b/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} \ No newline at end of file diff --git a/src/views/page/register/_ui/nickname.pug b/src/views/page/register/_ui/nickname.pug new file mode 100644 index 0000000..1b1abed --- /dev/null +++ b/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") diff --git a/src/views/page/register/_ui/password.pug b/src/views/page/register/_ui/password.pug new file mode 100644 index 0000000..2af11ba --- /dev/null +++ b/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} \ No newline at end of file diff --git a/src/views/page/register/_ui/username.pug b/src/views/page/register/_ui/username.pug new file mode 100644 index 0000000..bd909b7 --- /dev/null +++ b/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} \ No newline at end of file diff --git a/src/views/page/register/index.pug b/src/views/page/register/index.pug new file mode 100644 index 0000000..bfeea42 --- /dev/null +++ b/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 || '请求失败') + } + } + }); \ No newline at end of file diff --git a/src/views/page/register/style.css b/src/views/page/register/style.css new file mode 100644 index 0000000..68dd56e --- /dev/null +++ b/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; + } +} \ No newline at end of file