博客系统
项目概述
博客系统是一个功能丰富的Web应用程序,它将综合运用前面所学的所有PHP知识。通过开发博客系统,你将学习到更复杂的业务逻辑处理、文件上传、分页、搜索等高级功能。
项目目标
完成本项目后,你将能够:
- 掌握复杂业务逻辑的处理方法
- 学会实现文件上传功能
- 掌握分页显示技术
- 实现全文搜索功能
- 学习富文本编辑器的集成
- 掌握后台管理系统的开发
- 学会实现缓存机制
- 了解SEO优化技巧
功能特性
前台功能
- 文章列表(分页、分类筛选)
- 文章详情(阅读量统计、评论)
- 分类和标签系统
- 搜索功能(全文搜索)
- 评论系统
- 用户互动(点赞、收藏)
后台功能
- 文章管理(发布、编辑、删除)
- 分类标签管理
- 评论管理
- 用户管理
- 系统设置
- 数据统计
系统架构
目录结构
blog-system/
├── public/ # Web根目录
│ ├── index.php # 入口文件
│ ├── css/
│ │ ├── admin.css # 后台样式
│ │ ├── blog.css # 前台样式
│ │ └── editor.css # 编辑器样式
│ ├── js/
│ │ ├── admin.js # 后台脚本
│ │ ├── blog.js # 前台脚本
│ │ └── editor.js # 编辑器脚本
│ ├── uploads/
│ │ ├── images/ # 图片上传
│ │ └── files/ # 文件上传
│ └── assets/ # 静态资源
├── src/ # 源代码
│ ├── config/
│ │ ├── database.php # 数据库配置
│ │ ├── app.php # 应用配置
│ │ └── constants.php # 常量定义
│ ├── controllers/
│ │ ├── BaseController.php
│ │ ├── BlogController.php
│ │ ├── AdminController.php
│ │ ├── CommentController.php
│ │ └── UserController.php
│ ├── models/
│ │ ├── Database.php # 数据库基类
│ │ ├── Blog.php # 博客模型
│ │ ├── Category.php # 分类模型
│ │ ├── Tag.php # 标签模型
│ │ ├── Comment.php # 评论模型
│ │ ├── User.php # 用户模型
│ │ └── Upload.php # 文件上传模型
│ ├── views/
│ │ ├── layouts/
│ │ │ ├── header.php
│ │ │ ├── footer.php
│ │ │ ├── admin_header.php
│ │ │ └── admin_footer.php
│ │ ├── blog/
│ │ │ ├── index.php # 首页
│ │ │ ├── article.php # 文章详情
│ │ │ ├── category.php # 分类页面
│ │ │ ├── search.php # 搜索页面
│ │ │ └── tag.php # 标签页面
│ │ └── admin/
│ │ ├── dashboard.php # 仪表板
│ │ ├── articles.php # 文章管理
│ │ ├── categories.php # 分类管理
│ │ ├── comments.php # 评论管理
│ │ ├── users.php # 用户管理
│ │ └── settings.php # 系统设置
│ └── helpers/
│ ├── Validator.php # 验证助手
│ ├── Pagination.php # 分页助手
│ ├── FileManager.php # 文件管理
│ ├── Cache.php # 缓存助手
│ └── SEO.php # SEO助手
├── database/
│ ├── create_tables.sql # 建表SQL
│ └── seed_data.sql # 测试数据
├── cache/ # 缓存目录
├── logs/ # 日志目录
└── README.md
数据库设计
1. 创建数据库表
-- database/create_tables.sql
-- 文章表
CREATE TABLE articles (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content LONGTEXT NOT NULL,
excerpt TEXT,
featured_image VARCHAR(255),
author_id INT NOT NULL,
status ENUM('draft', 'published', 'trash') DEFAULT 'draft',
comment_status ENUM('open', 'closed') DEFAULT 'open',
view_count INT DEFAULT 0,
like_count INT DEFAULT 0,
is_featured BOOLEAN DEFAULT FALSE,
seo_title VARCHAR(255),
seo_description TEXT,
seo_keywords VARCHAR(255),
published_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 分类表
CREATE TABLE categories (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
parent_id INT NULL,
sort_order INT DEFAULT 0,
article_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
-- 标签表
CREATE TABLE tags (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
color VARCHAR(7) DEFAULT '#007bff',
article_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 文章分类关联表
CREATE TABLE article_categories (
id INT PRIMARY KEY AUTO_INCREMENT,
article_id INT NOT NULL,
category_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE KEY unique_article_category (article_id, category_id)
);
-- 文章标签关联表
CREATE TABLE article_tags (
id INT PRIMARY KEY AUTO_INCREMENT,
article_id INT NOT NULL,
tag_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE KEY unique_article_tag (article_id, tag_id)
);
-- 评论表
CREATE TABLE comments (
id INT PRIMARY KEY AUTO_INCREMENT,
article_id INT NOT NULL,
user_id INT NULL,
parent_id INT NULL,
author_name VARCHAR(100),
author_email VARCHAR(100),
author_ip VARCHAR(45),
content TEXT NOT NULL,
status ENUM('pending', 'approved', 'spam', 'trash') DEFAULT 'pending',
like_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
);
-- 用户表(复用用户认证系统的表)
-- 创建索引
CREATE INDEX idx_articles_status ON articles(status);
CREATE INDEX idx_articles_published_at ON articles(published_at);
CREATE INDEX idx_articles_author_id ON articles(author_id);
CREATE INDEX idx_articles_slug ON articles(slug);
CREATE INDEX idx_articles_featured ON articles(is_featured);
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
CREATE INDEX idx_categories_slug ON categories(slug);
CREATE INDEX idx_tags_slug ON tags(slug);
CREATE INDEX idx_comments_article_id ON comments(article_id);
CREATE INDEX idx_comments_status ON comments(status);
CREATE INDEX idx_comments_created_at ON comments(created_at);
-- 创建全文索引(用于搜索)
CREATE FULLTEXT INDEX ft_articles_title ON articles(title);
CREATE FULLTEXT INDEX ft_articles_content ON articles(content);
CREATE FULLTEXT INDEX ft_articles_full ON articles(title, content);
核心代码实现
1. 文章模型 (src/models/Blog.php)
<?php
require_once __DIR__ . '/../config/database.php';
class Blog extends BaseController {
// 获取文章列表
public function getArticles($page = 1, $limit = 10, $category = null, $tag = null) {
$offset = ($page - 1) * $limit;
$params = [];
$whereConditions = ["a.status = 'published'"];
// 添加分类筛选
if ($category) {
$whereConditions[] = "ac.category_id = ?";
$params[] = $category;
}
// 添加标签筛选
if ($tag) {
$whereConditions[] = "at.tag_id = ?";
$params[] = $tag;
}
$whereClause = "WHERE " . implode(" AND ", $whereConditions);
$sql = "SELECT DISTINCT a.*, u.username as author_name,
GROUP_CONCAT(DISTINCT c.name) as categories,
GROUP_CONCAT(DISTINCT t.name) as tags
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
LEFT JOIN article_categories ac ON a.id = ac.article_id
LEFT JOIN categories c ON ac.category_id = c.id
LEFT JOIN article_tags at ON a.id = at.article_id
LEFT JOIN tags t ON at.tag_id = t.id
{$whereClause}
GROUP BY a.id
ORDER BY a.published_at DESC
LIMIT ? OFFSET ?";
$params[] = $limit;
$params[] = $offset;
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// 获取文章总数
public function getArticlesCount($category = null, $tag = null) {
$params = [];
$whereConditions = ["a.status = 'published'"];
if ($category) {
$whereConditions[] = "ac.category_id = ?";
$params[] = $category;
}
if ($tag) {
$whereConditions[] = "at.tag_id = ?";
$params[] = $tag;
}
$whereClause = "WHERE " . implode(" AND ", $whereConditions);
$sql = "SELECT COUNT(DISTINCT a.id) as count
FROM articles a
LEFT JOIN article_categories ac ON a.id = ac.article_id
LEFT JOIN article_tags at ON a.id = at.article_id
{$whereClause}";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetch()['count'];
}
// 获取单篇文章
public function getArticleById($id) {
$sql = "SELECT a.*, u.username as author_name,
GROUP_CONCAT(DISTINCT c.name) as categories,
GROUP_CONCAT(DISTINCT c.id) as category_ids,
GROUP_CONCAT(DISTINCT t.name) as tags,
GROUP_CONCAT(DISTINCT t.id) as tag_ids
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
LEFT JOIN article_categories ac ON a.id = ac.article_id
LEFT JOIN categories c ON ac.category_id = c.id
LEFT JOIN article_tags at ON a.id = at.article_id
LEFT JOIN tags t ON at.tag_id = t.id
WHERE a.id = ?
GROUP BY a.id";
$stmt = $this->db->prepare($sql);
$stmt->execute([$id]);
$article = $stmt->fetch();
// 转换分类和标签为数组
if ($article) {
$article['categories'] = $article['categories'] ?
array_combine(
explode(',', $article['category_ids']),
explode(',', $article['categories'])
) : [];
$article['tags'] = $article['tags'] ?
array_combine(
explode(',', $article['tag_ids']),
explode(',', $article['tags'])
) : [];
}
return $article;
}
// 通过slug获取文章
public function getArticleBySlug($slug) {
$sql = "SELECT a.*, u.username as author_name
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
WHERE a.slug = ? AND a.status = 'published'";
$stmt = $this->db->prepare($sql);
$stmt->execute([$slug]);
return $stmt->fetch();
}
// 创建文章
public function createArticle($articleData) {
// 生成唯一的slug
$slug = $this->generateUniqueSlug($articleData['title']);
$sql = "INSERT INTO articles (title, slug, content, excerpt, featured_image,
author_id, status, seo_title, seo_description,
seo_keywords, is_featured, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())";
$params = [
$articleData['title'],
$slug,
$articleData['content'],
$articleData['excerpt'],
$articleData['featured_image'],
$articleData['author_id'],
$articleData['status'],
$articleData['seo_title'],
$articleData['seo_description'],
$articleData['seo_keywords'],
$articleData['is_featured'] ?? false,
$articleData['published_at'] ?? null
];
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$articleId = $this->db->lastInsertId();
// 保存分类和标签
if (!empty($articleData['categories'])) {
$this->saveArticleCategories($articleId, $articleData['categories']);
}
if (!empty($articleData['tags'])) {
$this->saveArticleTags($articleId, $articleData['tags']);
}
return $articleId;
}
// 更新文章
public function updateArticle($id, $articleData) {
// 如果标题改变,重新生成slug
if (isset($articleData['title']) && $articleData['title']) {
$articleData['slug'] = $this->generateUniqueSlug($articleData['title'], $id);
}
$fields = [];
$params = [];
$updateFields = [
'title', 'slug', 'content', 'excerpt', 'featured_image',
'status', 'seo_title', 'seo_description',
'seo_keywords', 'is_featured'
];
foreach ($updateFields as $field) {
if (isset($articleData[$field])) {
$fields[] = "{$field} = ?";
$params[] = $articleData[$field];
}
}
if (isset($articleData['published_at'])) {
$fields[] = "published_at = ?";
$params[] = $articleData['published_at'];
}
$fields[] = "updated_at = NOW()";
$params[] = $id;
$sql = "UPDATE articles SET " . implode(', ', $fields) . " WHERE id = ?";
$stmt = $this->db->prepare($sql);
$result = $stmt->execute($params);
// 更新分类和标签
if (isset($articleData['categories'])) {
$this->saveArticleCategories($id, $articleData['categories']);
}
if (isset($articleData['tags'])) {
$this->saveArticleTags($id, $articleData['tags']);
}
return $result;
}
// 删除文章
public function deleteArticle($id) {
// 开始事务
$this->db->beginTransaction();
try {
// 删除文章分类关联
$this->db->prepare("DELETE FROM article_categories WHERE article_id = ?")
->execute([$id]);
// 删除文章标签关联
$this->db->prepare("DELETE FROM article_tags WHERE article_id = ?")
->execute([$id]);
// 删除文章
$this->db->prepare("DELETE FROM articles WHERE id = ?")
->execute([$id]);
$this->db->commit();
return true;
} catch (Exception $e) {
$this->db->rollBack();
return false;
}
}
// 搜索文章
public function searchArticles($keyword, $page = 1, $limit = 10) {
$offset = ($page - 1) * $limit;
// 使用全文搜索
$sql = "SELECT a.*, u.username as author_name,
MATCH(a.title, a.content) AGAINST(?) as score
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
WHERE a.status = 'published'
AND MATCH(a.title, a.content) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY score DESC, a.published_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$keyword, $keyword, $limit, $offset]);
return $stmt->fetchAll();
}
// 获取搜索结果总数
public function getSearchCount($keyword) {
$sql = "SELECT COUNT(*) as count
FROM articles a
WHERE a.status = 'published'
AND MATCH(a.title, a.content) AGAINST(? IN NATURAL LANGUAGE MODE)";
$stmt = $this->db->prepare($sql);
$stmt->execute([$keyword]);
return $stmt->fetch()['count'];
}
// 增加阅读量
public function incrementViewCount($id) {
$sql = "UPDATE articles SET view_count = view_count + 1 WHERE id = ?";
$stmt = $this->db->prepare($sql);
return $stmt->execute([$id]);
}
// 点赞/取消点赞
public function toggleLike($articleId, $userId) {
// 检查是否已经点赞
$sql = "SELECT * FROM article_likes WHERE article_id = ? AND user_id = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$articleId, $userId]);
if ($stmt->fetch()) {
// 取消点赞
$this->db->prepare("DELETE FROM article_likes WHERE article_id = ? AND user_id = ?")
->execute([$articleId, $userId]);
$this->db->prepare("UPDATE articles SET like_count = like_count - 1 WHERE id = ?")
->execute([$articleId]);
return false;
} else {
// 添加点赞
$this->db->prepare("INSERT INTO article_likes (article_id, user_id, created_at) VALUES (?, ?, NOW())")
->execute([$articleId, $userId]);
$this->db->prepare("UPDATE articles SET like_count = like_count + 1 WHERE id = ?")
->execute([$articleId]);
return true;
}
}
// 生成唯一的slug
private function generateUniqueSlug($title, $id = null) {
$slug = $this->createSlug($title);
$originalSlug = $slug;
$count = 1;
// 检查slug是否唯一
while (true) {
$sql = "SELECT id FROM articles WHERE slug = ?";
$params = [$slug];
if ($id) {
$sql .= " AND id != ?";
$params[] = $id;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
if (!$stmt->fetch()) {
return $slug;
}
$slug = $originalSlug . '-' . $count;
$count++;
}
}
// 创建slug
private function createSlug($string) {
// 转换为小写
$string = strtolower($string);
// 替换特殊字符
$string = preg_replace('/[^a-z0-9\s-]/', '', $string);
// 替换多个空格为单个连字符
$string = preg_replace('/[\s-]+/', '-', $string);
// 删除首尾的连字符
return trim($string, '-');
}
// 保存文章分类
private function saveArticleCategories($articleId, $categories) {
// 删除现有的分类关联
$this->db->prepare("DELETE FROM article_categories WHERE article_id = ?")
->execute([$articleId]);
// 添加新的分类关联
$sql = "INSERT INTO article_categories (article_id, category_id, created_at) VALUES (?, ?, NOW())";
$stmt = $this->db->prepare($sql);
foreach ($categories as $categoryId) {
$stmt->execute([$articleId, $categoryId]);
}
}
// 保存文章标签
private function saveArticleTags($articleId, $tags) {
// 删除现有的标签关联
$this->db->prepare("DELETE FROM article_tags WHERE article_id = ?")
->execute([$articleId]);
// 添加新的标签关联
$sql = "INSERT INTO article_tags (article_id, tag_id, created_at) VALUES (?, ?, NOW())";
$stmt = $this->db->prepare($sql);
foreach ($tags as $tagId) {
$stmt->execute([$articleId, $tagId]);
}
}
}
2. 博客控制器 (src/controllers/BlogController.php)
<?php
require_once 'BaseController.php';
require_once __DIR__ . '/../models/Blog.php';
require_once __DIR__ . '/../models/Category.php';
require_once __DIR__ . '/../models/Tag.php';
require_once __DIR__ . '/../models/Comment.php';
require_once __DIR__ . '/../helpers/Pagination.php';
class BlogController extends BaseController {
private $blog;
private $category;
private $tag;
private $comment;
public function __construct() {
parent::__construct();
$this->blog = new Blog();
$this->category = new Category();
$this->tag = new Tag();
$this->comment = new Comment();
}
// 首页 - 文章列表
public function index() {
$page = $this->getGet('page', 1);
$category = $this->getGet('category');
$tag = $this->getGet('tag');
$articles = $this->blog->getArticles($page, 10, $category, $tag);
$totalArticles = $this->blog->getArticlesCount($category, $tag);
$pagination = new Pagination($totalArticles, $page, 10, 5);
$pagination->setUrl($_SERVER['REQUEST_URI']);
$this->render('blog/index.php', [
'articles' => $articles,
'pagination' => $pagination,
'categories' => $this->category->getAll(),
'popularTags' => $this->tag->getPopular(10)
]);
}
// 文章详情
public function article() {
$slug = $this->getGet('slug');
if (!$slug) {
$this->render404();
}
$article = $this->blog->getArticleBySlug($slug);
if (!$article) {
$this->render404();
}
// 增加阅读量
$this->blog->incrementViewCount($article['id']);
// 获取评论
$comments = $this->comment->getApprovedComments($article['id']);
$relatedArticles = $this->getRelatedArticles($article);
$this->render('blog/article.php', [
'article' => $article,
'comments' => $comments,
'relatedArticles' => $relatedArticles
]);
}
// 分类页面
public function category() {
$slug = $this->getGet('slug');
$page = $this->getGet('page', 1);
$category = $this->category->getBySlug($slug);
if (!$category) {
$this->render404();
}
$articles = $this->blog->getArticles($page, 10, $category['id']);
$totalArticles = $this->blog->getArticlesCount($category['id']);
$pagination = new Pagination($totalArticles, $page, 10);
$pagination->setUrl($_SERVER['REQUEST_URI']);
$this->render('blog/category.php', [
'category' => $category,
'articles' => $articles,
'pagination' => $pagination
]);
}
// 标签页面
public function tag() {
$slug = $this->getGet('slug');
$page = $this->getGet('page', 1);
$tag = $this->tag->getBySlug($slug);
if (!$tag) {
$this->render404();
}
$articles = $this->blog->getArticles($page, 10, null, $tag['id']);
$totalArticles = $this->blog->getArticlesCount(null, $tag['id']);
$pagination = new Pagination($totalArticles, $page, 10);
$pagination->setUrl($_SERVER['REQUEST_URI']);
$this->render('blog/tag.php', [
'tag' => $tag,
'articles' => $articles,
'pagination' => $pagination
]);
}
// 搜索页面
public function search() {
$keyword = trim($this->getGet('q'));
$page = $this->getGet('page', 1);
if (!$keyword) {
$this->redirect('index.php');
}
$articles = $this->blog->searchArticles($keyword, $page, 10);
$totalResults = $this->blog->getSearchCount($keyword);
$pagination = new Pagination($totalResults, $page, 10);
$pagination->setUrl($_SERVER['REQUEST_URI']);
$this->render('blog/search.php', [
'keyword' => $keyword,
'articles' => $articles,
'totalResults' => $totalResults,
'pagination' => $pagination
]);
}
// 提交评论
public function submitComment() {
if (!$this->isPost()) {
$this->json(['success' => false, 'message' => '无效的请求']);
}
$articleId = $this->getPost('article_id');
$parentId = $this->getPost('parent_id');
$content = trim($this->getPost('content'));
$authorName = trim($this->getPost('author_name'));
$authorEmail = trim($this->getPost('author_email'));
// 验证数据
if (!$articleId || !$content) {
$this->json(['success' => false, 'message' => '请填写必填字段']);
}
if (!$authorName || !$authorEmail) {
$this->json(['success' => false, 'message' => '请填写姓名和邮箱']);
}
if (!filter_var($authorEmail, FILTER_VALIDATE_EMAIL)) {
$this->json(['success' => false, 'message' => '邮箱格式错误']);
}
// 检查文章是否存在
if (!$this->blog->getArticleById($articleId)) {
$this->json(['success' => false, 'message' => '文章不存在']);
}
// 防止重复提交
if (isset($_SESSION['last_comment_time']) && time() - $_SESSION['last_comment_time'] < 10) {
$this->json(['success' => false, 'message' => '请稍后再试']);
}
// 验证码验证(如果需要)
if (isset($_POST['captcha'])) {
if (!$this->validateCaptcha($_POST['captcha'])) {
$this->json(['success' => false, 'message' => '验证码错误']);
}
}
// 提交评论
$commentId = $this->comment->create([
'article_id' => $articleId,
'user_id' => $this->getSession('user_id'),
'parent_id' => $parentId ?: null,
'author_name' => $authorName,
'author_email' => $authorEmail,
'content' => $content,
'author_ip' => $_SERVER['REMOTE_ADDR'],
'status' => 'pending' // 评论需要审核
]);
if ($commentId) {
$_SESSION['last_comment_time'] = time();
$this->json([
'success' => true,
'message' => '评论提交成功,等待审核'
]);
} else {
$this->json(['success' => false, 'message' => '评论提交失败']);
}
}
// AJAX点赞
public function like() {
if (!$this->isPost()) {
$this->json(['success' => false, 'message' => '无效的请求']);
}
$articleId = $this->getPost('article_id');
$userId = $this->getSession('user_id');
if (!$articleId) {
$this->json(['success' => false, 'message' => '参数错误']);
}
if (!$userId) {
$this->json(['success' => false, 'message' => '请先登录']);
}
$isLiked = $this->blog->toggleLike($articleId, $userId);
$article = $this->blog->getArticleById($articleId);
$this->json([
'success' => true,
'liked' => $isLiked,
'likeCount' => $article['like_count'] ?? 0
]);
}
// 获取相关文章
private function getRelatedArticles($article, $limit = 4) {
// 基于标签查找相关文章
$tagIds = array_keys($article['tags']);
if (empty($tagIds)) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tagIds), '?'));
$sql = "SELECT DISTINCT a.id, a.title, a.slug, a.excerpt, a.featured_image,
a.published_at, a.view_count
FROM articles a
LEFT JOIN article_tags at ON a.id = at.article_id
WHERE a.id != ? AND a.status = 'published'
AND at.tag_id IN ({$placeholders})
ORDER BY a.published_at DESC
LIMIT ?";
$params = array_merge([$article['id']], $tagIds, [$limit]);
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// 验证码验证
private function validateCaptcha($captcha) {
// 简单的验证码验证
// 实际项目中可以使用 reCAPTCHA
if (isset($_SESSION['captcha'])) {
return hash_equals($_SESSION['captcha'], $captcha);
}
return true;
}
// 404页面
private function render404() {
header('HTTP/1.0 404 Not Found');
$this->render('blog/404.php');
exit;
}
}
3. 分页助手 (src/helpers/Pagination.php)
<?php
class Pagination {
private $totalItems;
private $currentPage;
private $itemsPerPage;
private $maxVisiblePages;
private $url;
private $totalPages;
public function __construct($totalItems, $currentPage = 1, $itemsPerPage = 10, $maxVisiblePages = 7) {
$this->totalItems = $totalItems;
$this->currentPage = max(1, $currentPage);
$this->itemsPerPage = $itemsPerPage;
$this->maxVisiblePages = $maxVisiblePages;
$this->totalPages = ceil($totalItems / $itemsPerPage);
}
public function setUrl($url) {
// 移除现有的分页参数
$url = preg_replace('/[?&]page=\d+/', '', $url);
// 添加分隔符
$separator = (strpos($url, '?') === false) ? '?' : '&';
$this->url = $url . $separator . 'page=';
}
public function createLinks() {
if ($this->totalPages <= 1) {
return '';
}
$links = '<nav aria-label="Page navigation"><ul class="pagination justify-content-center">';
// 上一页
if ($this->currentPage > 1) {
$links .= '<li class="page-item"><a class="page-link" href="' . $this->url . ($this->currentPage - 1) . '">上一页</a></li>';
} else {
$links .= '<li class="page-item disabled"><span class="page-link">上一页</span></li>';
}
// 页码
$startPage = max(1, $this->currentPage - floor($this->maxVisiblePages / 2));
$endPage = min($this->totalPages, $startPage + $this->maxVisiblePages - 1);
// 调整开始页
if ($endPage - $startPage < $this->maxVisiblePages - 1) {
$startPage = max(1, $endPage - $this->maxVisiblePages + 1);
}
// 第一页
if ($startPage > 1) {
$links .= '<li class="page-item"><a class="page-link" href="' . $this->url . '1">1</a></li>';
if ($startPage > 2) {
$links .= '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
}
// 中间页码
for ($i = $startPage; $i <= $endPage; $i++) {
if ($i == $this->currentPage) {
$links .= '<li class="page-item active"><span class="page-link">' . $i . '</span></li>';
} else {
$links .= '<li class="page-item"><a class="page-link" href="' . $this->url . $i . '">' . $i . '</a></li>';
}
}
// 最后一页
if ($endPage < $this->totalPages) {
if ($endPage < $this->totalPages - 1) {
$links .= '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
$links .= '<li class="page-item"><a class="page-link" href="' . $this->url . $this->totalPages . '">' . $this->totalPages . '</a></li>';
}
// 下一页
if ($this->currentPage < $this->totalPages) {
$links .= '<li class="page-item"><a class="page-link" href="' . $this->url . ($this->currentPage + 1) . '">下一页</a></li>';
} else {
$links .= '<li class="page-item disabled"><span class="page-link">下一页</span></li>';
}
$links .= '</ul></nav>';
return $links;
}
public function getInfo() {
$start = ($this->currentPage - 1) * $this->itemsPerPage + 1;
$end = min($this->totalItems, $start + $this->itemsPerPage - 1);
return [
'total' => $this->totalItems,
'current' => $this->currentPage,
'start' => $this->totalItems > 0 ? $start : 0,
'end' => $end,
'pages' => $this->totalPages,
'items_per_page' => $this->itemsPerPage
];
}
}
4. 文章管理视图示例 (src/views/admin/articles.php)
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">文章管理</h5>
<a href="article-edit.php" class="btn btn-primary">
<i class="fas fa-plus"></i> 新建文章
</a>
</div>
<div class="card-body">
<!-- 搜索和筛选 -->
<div class="row mb-3">
<div class="col-md-6">
<div class="input-group">
<input type="text" class="form-control" id="searchInput" placeholder="搜索文章...">
<button class="btn btn-outline-secondary" type="button" id="searchBtn">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="col-md-3">
<select class="form-select" id="statusFilter">
<option value="">所有状态</option>
<option value="published">已发布</option>
<option value="draft">草稿</option>
<option value="trash">回收站</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select" id="categoryFilter">
<option value="">所有分类</option>
<?php foreach ($categories as $category): ?>
<option value="<?php echo $category['id']; ?>">
<?php echo htmlspecialchars($category['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- 文章表格 -->
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th><input type="checkbox" id="selectAll"></th>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>状态</th>
<th>发布时间</th>
<th>阅读量</th>
<th>操作</th>
</tr>
</thead>
<tbody id="articlesTable">
<?php foreach ($articles as $article): ?>
<tr>
<td><input type="checkbox" name="article_ids[]" value="<?php echo $article['id']; ?>"></td>
<td>
<a href="article-edit.php?id=<?php echo $article['id']; ?>">
<?php echo htmlspecialchars($article['title']); ?>
</a>
<?php if ($article['is_featured']): ?>
<span class="badge bg-warning">推荐</span>
<?php endif; ?>
</td>
<td><?php echo htmlspecialchars($article['author_name']); ?></td>
<td><?php echo $article['categories']; ?></td>
<td>
<?php
$statusClass = [
'draft' => 'secondary',
'published' => 'success',
'trash' => 'danger'
];
$statusText = [
'draft' => '草稿',
'published' => '已发布',
'trash' => '回收站'
];
?>
<span class="badge bg-<?php echo $statusClass[$article['status']]; ?>">
<?php echo $statusText[$article['status']]; ?>
</span>
</td>
<td><?php echo date('Y-m-d', strtotime($article['published_at'] ?? $article['created_at'])); ?></td>
<td><?php echo number_format($article['view_count']); ?></td>
<td>
<div class="btn-group btn-group-sm">
<a href="article-edit.php?id=<?php echo $article['id']; ?>"
class="btn btn-outline-primary">
<i class="fas fa-edit"></i>
</a>
<a href="article-view.php?id=<?php echo $article['id']; ?>"
class="btn btn-outline-info" target="_blank">
<i class="fas fa-eye"></i>
</a>
<button type="button" class="btn btn-outline-danger delete-btn"
data-id="<?php echo $article['id']; ?>">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- 分页 -->
<?php echo $pagination->createLinks(); ?>
</div>
</div>
</div>
</div>
</div>
<!-- 批量操作模态框 -->
<div class="modal fade" id="batchModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">批量操作</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>请选择要执行的操作:</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" id="batchPublish">发布选中</button>
<button type="button" class="btn btn-outline-secondary" id="batchDraft">移至草稿</button>
<button type="button" class="btn btn-outline-danger" id="batchTrash">移至回收站</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 全选/取消全选
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('input[name="article_ids[]"]');
selectAll?.addEventListener('change', function() {
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
});
// 搜索功能
const searchBtn = document.getElementById('searchBtn');
const searchInput = document.getElementById('searchInput');
function performSearch() {
const keyword = searchInput.value;
const status = document.getElementById('statusFilter').value;
const category = document.getElementById('categoryFilter').value;
const params = new URLSearchParams();
if (keyword) params.append('search', keyword);
if (status) params.append('status', status);
if (category) params.append('category', category);
window.location.href = 'articles.php?' + params.toString();
}
searchBtn?.addEventListener('click', performSearch);
searchInput?.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// 状态和分类筛选
document.getElementById('statusFilter')?.addEventListener('change', performSearch);
document.getElementById('categoryFilter')?.addEventListener('change', performSearch);
// 删除按钮
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', function() {
const articleId = this.dataset.id;
if (confirm('确定要删除这篇文章吗?')) {
fetch('api/article-delete.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: articleId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('删除失败:' + data.message);
}
});
}
});
});
});
</script>
5. 富文本编辑器集成
编辑器配置 (public/js/editor.js)
// 使用TinyMCE富文本编辑器
tinymce.init({
selector: '#content',
height: 500,
theme: 'silver',
language: 'zh_CN',
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount',
'codesample', 'emoticons'
],
toolbar: 'undo redo | formatselect | bold italic | alignleft aligncenter alignright alignjustify | ' +
'bullist numlist outdent indent | link image | preview media | ' +
'forecolor backcolor emoticons | help codesample',
menubar: 'file edit view insert format tools table help',
content_style: `
body { font-family: Arial, sans-serif; font-size: 14px; }
img { max-width: 100%; height: auto; }
pre { background: #f4f4f4; border: 1px solid #ddd; padding: 10px; border-radius: 4px; }
code { background: #f4f4f4; padding: 2px 4px; border-radius: 2px; }
`,
image_advtab: true,
image_uploadtab: true,
automatic_uploads: true,
images_upload_url: 'api/upload-image.php',
images_upload_handler: function(blobInfo, success, failure) {
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', 'api/upload-image.php');
xhr.onload = function() {
if (xhr.status === 200) {
const json = JSON.parse(xhr.responseText);
success(json.location);
} else {
failure('Image upload failed: ' + xhr.statusText);
}
};
xhr.send(blobInfo.blob());
},
// 自定义样式
style_formats: [
{title: 'Heading 2', block: 'h2', classes: 'text-primary'},
{title: 'Heading 3', block: 'h3', classes: 'text-secondary'},
{title: 'Button', inline: 'span', classes: 'btn btn-primary'},
{title: 'Badge', inline: 'span', classes: 'badge bg-primary'}
]
});
// 字数统计
function updateWordCount() {
const content = tinymce.get('content').getContent();
const plainText = content.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const wordCount = plainText ? plainText.split(' ').length : 0;
document.getElementById('wordCount').textContent = wordCount + ' 字';
}
// 自动保存功能
let autoSaveTimer;
function startAutoSave() {
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
}
autoSaveTimer = setInterval(function() {
saveDraft();
}, 30000); // 30秒自动保存一次
}
// 保存草稿
function saveDraft() {
const content = tinymce.get('content').getContent();
const title = document.getElementById('title').value;
if (!title || !content) return;
fetch('api/save-draft.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: title,
content: content,
id: document.getElementById('article_id')?.value
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('saveStatus').textContent = '已自动保存';
setTimeout(() => {
document.getElementById('saveStatus').textContent = '';
}, 3000);
}
});
}
后台管理系统
管理员仪表板 (src/views/admin/dashboard.php)
<div class="container-fluid">
<!-- 统计卡片 -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
总文章数
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
<?php echo $stats['total_articles']; ?>
</div>
</div>
<div class="col-auto">
<i class="fas fa-file-alt fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
已发布
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
<?php echo $stats['published_articles']; ?>
</div>
</div>
<div class="col-auto">
<i class="fas fa-check-circle fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
总评论数
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
<?php echo $stats['total_comments']; ?>
</div>
</div>
<div class="col-auto">
<i class="fas fa-comments fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
总用户数
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
<?php echo $stats['total_users']; ?>
</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="row mb-4">
<div class="col-xl-8 col-lg-7">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">文章发布趋势</h6>
</div>
<div class="card-body">
<div class="chart-area">
<canvas id="articleChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-lg-5">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">文章分类分布</h6>
</div>
<div class="card-body">
<div class="chart-pie pt-4 pb-2">
<canvas id="categoryChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- 最近文章和评论 -->
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">最近文章</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>标题</th>
<th>作者</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentArticles as $article): ?>
<tr>
<td>
<a href="article-edit.php?id=<?php echo $article['id']; ?>">
<?php echo htmlspecialchars(substr($article['title'], 0, 30)); ?>...
</a>
</td>
<td><?php echo htmlspecialchars($article['author_name']); ?></td>
<td>
<span class="badge bg-<?php echo $article['status'] === 'published' ? 'success' : 'secondary'; ?>">
<?php echo $article['status']; ?>
</span>
</td>
<td><?php echo date('m-d', strtotime($article['created_at'])); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">待审核评论</h6>
</div>
<div class="card-body">
<?php foreach ($pendingComments as $comment): ?>
<div class="mb-3 p-3 border rounded">
<div class="d-flex justify-content-between">
<strong><?php echo htmlspecialchars($comment['author_name']); ?></strong>
<small class="text-muted"><?php echo date('m-d H:i', strtotime($comment['created_at'])); ?></small>
</div>
<p class="mb-2"><?php echo htmlspecialchars(substr($comment['content'], 0, 100)); ?>...</p>
<div class="d-flex gap-2">
<a href="comment-edit.php?id=<?php echo $comment['id']; ?>" class="btn btn-sm btn-outline-primary">审核</a>
<a href="#" class="btn btn-sm btn-outline-danger delete-comment" data-id="<?php echo $comment['id']; ?>">删除</a>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($pendingComments)): ?>
<p class="text-muted">暂无待审核评论</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// 文章发布趋势图
const articleCtx = document.getElementById('articleChart').getContext('2d');
new Chart(articleCtx, {
type: 'line',
data: {
labels: <?php echo json_encode($stats['article_trend']['labels']); ?>,
datasets: [{
label: '文章数量',
data: <?php echo json_encode($stats['article_trend']['data']); ?>,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
// 分类分布饼图
const categoryCtx = document.getElementById('categoryChart').getContext('2d');
new Chart(categoryCtx, {
type: 'doughnut',
data: {
labels: <?php echo json_encode($stats['category_distribution']['labels']); ?>,
datasets: [{
data: <?php echo json_encode($stats['category_distribution']['data']); ?>,
backgroundColor: [
'#4e73df',
'#1cc88a',
'#36b9cc',
'#f6c23e',
'#e74a3b',
'#858796',
'#5a5c69'
]
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
</script>
性能优化
1. 缓存实现 (src/helpers/Cache.php)
<?php
class Cache {
private $cacheDir;
private $defaultExpire = 3600; // 1小时
public function __construct($cacheDir = 'cache/') {
$this->cacheDir = $cacheDir;
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0777, true);
}
}
// 设置缓存
public function set($key, $data, $expire = null) {
$expire = $expire ?? $this->defaultExpire;
$filename = $this->getFilename($key);
$cacheData = [
'data' => $data,
'expire' => time() + $expire,
'created' => time()
];
file_put_contents($filename, serialize($cacheData), LOCK_EX);
return true;
}
// 获取缓存
public function get($key) {
$filename = $this->getFilename($key);
if (!file_exists($filename)) {
return null;
}
$cacheData = unserialize(file_get_contents($filename));
if (time() > $cacheData['expire']) {
unlink($filename);
return null;
}
return $cacheData['data'];
}
// 删除缓存
public function delete($key) {
$filename = $this->getFilename($key);
if (file_exists($filename)) {
return unlink($filename);
}
return true;
}
// 清空所有缓存
public function clear() {
$files = glob($this->cacheDir . '*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
}
}
}
// 生成缓存文件名
private function getFilename($key) {
return $this->cacheDir . md5($key) . '.cache';
}
// 缓存数据库查询结果
public function remember($key, $callback, $expire = null) {
$data = $this->get($key);
if ($data === null) {
$data = call_user_func($callback);
$this->set($key, $data, $expire);
}
return $data;
}
}
2. 使用示例
// 在控制器中使用缓存
$cache = new Cache();
// 缓存热门文章
$popularArticles = $cache->remember('popular_articles', function() use ($blog) {
return $blog->getPopularArticles(5);
}, 1800); // 30分钟缓存
// 缓存分类列表
$categories = $cache->remember('categories', function() use ($category) {
return $category->getAll();
}, 3600); // 1小时缓存
部署和测试
1. 功能测试清单
## 博客系统测试清单
### 文章管理
- [ ] 创建文章
- [ ] 编辑文章
- [ ] 删除文章
- [ ] 文章发布/草稿状态切换
- [ ] 特色文章设置
- [ ] SEO字段设置
- [ ] 富文本编辑器
- [ ] 图片上传功能
### 分类和标签
- [ ] 创建分类
- [ ] 编辑分类
- [ ] 删除分类
- [ ] 分类层级关系
- [ ] 创建标签
- [ ] 标签与文章关联
### 评论系统
- [ ] 提交评论
- [ ] 评论审核
- [ ] 评论回复
- [ ] 垃圾评论过滤
### 搜索功能
- [ ] 全文搜索
- [ ] 分类搜索
- [ ] 标签搜索
- [ ] 搜索结果分页
### 性能优化
- [ ] 页面加载速度
- [ ] 数据库查询优化
- [ ] 缓存机制
- [ ] 图片优化
总结
通过开发博客系统,你已经:
- 掌握了复杂业务逻辑处理:学会了如何处理复杂的业务需求
- 实现了文件上传功能:了解了文件上传的安全性和处理方法
- 学会了分页显示:掌握了大量数据分页展示的技术
- 实现了搜索功能:学会了全文搜索的实现方法
- 集成了富文本编辑器:掌握了第三方库的集成方法
- 开发了后台管理系统:了解了完整的后台系统架构
- 实施了性能优化:学会了缓存等优化技术的使用
- 实践了SEO优化:了解了网站SEO的基本要求
这个博客系统是一个完整的PHP Web应用,它涵盖了现代Web开发的许多重要方面。你可以基于这个系统继续扩展,开发更多功能,如:
- 用户关注系统
- 文章收藏功能
- 邮件订阅功能
- 社交媒体集成
- 多语言支持
- 移动端适配