简介:这是一份基于ThinkPHP框架的新闻发布系统前后台完整资源,面向PHP初学者与Web项目实战开发者,帮助理解MVC分层、数据交互及富文本编辑器集成。压缩包仅6.43MB,却包含1117个文件:414个PHP文件承载核心业务逻辑,503个GIF多为界面效果或操作演示,另有HTML/CSS/JS前端页面、SQL数据库脚本及UEditor扩展组件,便于按模块对照学习。已有4735人学习下载。资源围绕文章后台管理展开,涵盖数据表设计、Article控制器与模型编写、模板渲染、图片上传接口配置,以及前端列表分页和搜索功能;同时涉及SQL注入、XSS防护与缓存策略等安全优化要点。配合内容预览中的IndexAction等关键文件,可直接部署运行,也可作为课程设计的参考蓝图。
1. 用 ThinkPHP 搭新闻系统,三天交付前后台
前段时间接了一个新闻发布网站的单子,需求不复杂但很琐碎:前台列表页、详情页,后台要有登录、栏目管理、新闻发布和审核状态切换。原本以为要用原生 PHP 慢慢写,结果翻出之前收藏的这套 ThinkPHP 新闻发布系统资源,前台后台都有,按 MVC 结构拆得很清楚,从环境配置到能提交新闻,前后用了三天。
这套资源适合两类人:一类是刚学 ThinkPHP 想找个完整项目练手的新手,另一类是像我一样要快速交付中小型网站的开发。它把数据库、后台登录、UEditor 富文本编辑器全部串了起来,相当于给你搭好骨架,你只需要改改样式和业务细节就能上线。原始需求里常见的栏目管理、新闻发布、状态审核都在里面,基本不用自己从零造轮子。
2. 环境搭建与项目初始化:MVC 目录结构、入口文件与数据库连接
2.1 先说 MVC:ThinkPHP 的项目目录到底怎么组织
刚开始用 ThinkPHP(后面简称 TP)的人,最容易被目录结构劝退。它的 MVC 分层其实很直观:M 是模型,对应数据库表的操作;V 是视图,负责 HTML 页面渲染;C 是控制器,接收请求、调用模型、把数据抛给视图。这套新闻系统把前后台都放在同一个项目里,后台代码和前台代码分别放在不同的控制器目录,通过 URL 前段区分。
常见的目录结构如下:
tp_news/ ├── app/ │ ├── controller/ # 控制器,前台和后台的类都放在这 │ ├── model/ # 模型,对应新闻、栏目、管理员等表 │ └── view/ # 模板,HTML 页面 ├── config/ │ └── database.php # 数据库连接配置 ├── public/ │ ├── index.php # 入口文件 │ └── static/ # css、js、图片 ├── route/ │ └── app.php # 路由定义 └── runtime/ # 缓存和日志目录实际项目里还会多出 extend、vendor 之类的内容。你要记住的核心是:控制器只做调度,不直接写 SQL;模型层封装数据查询;视图层只负责输出 HTML。很多新手图省事,在控制器里用原生 SQL 一把梭,短期看起来快,后面改一个字段名要搜遍全站。你既然用了 MVC 框架,就顺着它的规矩走,后面维护成本会低很多。
2.2 环境准备与入口文件配置
这套系统跑的是 PHP 7 环境,本地调试建议直接用集成环境,常见的集成包自带 Apache + MySQL + PHP,省去自己配环境的麻烦。解压源码后把 Web 根目录指向 public 目录,访问域名就能看到首页。如果看到的是目录列表而不是页面,说明入口文件没有被正确解析,或者伪静态规则没配。
入口文件 public/index.php 很简单,它的作用只有两个:引入框架启动文件、初始化应用。
<?php // public/index.php namespace think; require __DIR__ . '/../vendor/autoload.php'; // 开启调试模式,上线前记得关掉 $app = new \think\App(); $app->initialize();如果你用的是精简版 PHP 环境,很可能缺少 vendor 目录里的依赖文件。这时在项目根目录运行 composer install 拉一遍依赖就能解决。没装 Composer 的读者,可以找一套带完整 vendor 的压缩包直接解压,这也是这套资源的优势之一,离线环境也能跑起来调试。
2.3 数据库连接配置:config/database.php 的关键参数
框架跑起来之后,第一件事是改数据库配置。文件位置在 config/database.php,核心参数是主机、库名、账号、密码,还有一个容易忽略的 prefix。
// config/database.php return [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'tp_news', 'username' => 'root', 'password' => 'your_password', 'hostport' => '3306', 'prefix' => 'tp_', 'charset' => 'utf8mb4', ];不少人在这一步翻车:prefix 填了 tp_,但导入 SQL 时表名没有带这个前缀,然后模型查询报“表不存在”。解决办法很简单,要么把 SQL 里的表名都加上 tp_ 前缀,要么把 prefix 改成空字符串。我一般习惯保留一个固定前缀,因为同一个数据库里可能还有别的应用,加了前缀不容易互相覆盖。
字符集也要注意,老项目很多还是 utf8,建议你直接改成 utf8mb4,否则以后用户往标题里粘 emoji 表情,存进数据库会显示成乱码,而且报错很隐蔽。SQL 文件一般在源码包的 sql 目录下,用 phpMyAdmin 导入或者命令行导入都行:
mysql -uroot -p tp_news < sql/tp_news.sql导入完成后用后台初始账号登进去,第一件事就是改密码。初始密码一般写在 README 或 SQL 文件的注释里,这个别忽略,线上环境用默认密码等于裸奔。
2.4 URL 重写与调试模式的选择
ThinkPHP 默认的 URL 访问方式是 index.php?s=/index/news/read&id=1 这种模式,能跑,但地址非常丑,对 SEO 也不友好。这套系统自带伪静态规则,Apache 下在 public 目录放一个 .htaccess,Nginx 下在站点配置里加一段 rewrite,比较常见的写法是:
location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } }配好伪静态之后,访问地址会变成 news/read/id/1 这种简洁形式,前后台链接看起来也专业很多。
调试模式是新手经常忽略的地方。这套资源里调试开关在 config/app.php 中,设置为 'app_debug' => true 后页面上会直接显示报错信息。本地开发一定要开着,代码出问题一目了然。上线前务必关掉,否则 SQL 错误、文件路径全都会暴露给访问者,这些信息对攻击者来说等于送上门的地图。
3. 数据库设计与模型层:新闻表字段、索引和查询封装
3.1 新闻表字段设计:预留哪些状态位才能不发散
做新闻系统,最忌讳的就是把状态直接设计成「发布/未发布」两个枚举值。到了项目后期,你会发现需求里还要塞「草稿」「待审核」「定时发布」,字段改来改去非常痛苦。这套系统的 news 表字段设计得很常规,但状态位的取舍值得参考:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int(11) | 主键 |
| title | varchar(200) | 新闻标题 |
| content | text | 正文内容 |
| category_id | int(11) | 栏目ID |
| status | tinyint(1) | 0草稿 1待审核 2已发布 3已下架 |
| views | int(11) | 浏览量 |
| create_time | int(10) | 创建时间(时间戳) |
| update_time | int(10) | 更新时间 |
用 tinyint 做 status 而不是直接用布尔值的原因很简单:新闻发布流程天然带审核这个动作。管理员编辑完保存为草稿,提交后进入待审核,审核通过才能对外展示。如果一开始就设计成两个状态,后面加审核环节就得改表结构,SQL 迁移在小项目里还能忍,但没必要给自己埋这个坑。
3.2 建表 SQL 与索引设计
建表语句直接看代码,下面的结构基本能对应到这套系统的核心表:
CREATE TABLE `tp_news` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '标题', `content` text COMMENT '正文', `category_id` int(11) NOT NULL DEFAULT 0 COMMENT '栏目ID', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1待审核 2已发布 3已下架', `views` int(11) NOT NULL DEFAULT 0 COMMENT '浏览量', `create_time` int(10) NOT NULL DEFAULT 0, `update_time` int(10) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;索引这里有个细节值得说:idx_category_status 是联合索引。前台列表页最常见的操作是「按栏目展示已发布的新闻」,查询条件同时带上 category_id 和 status,这一个索引能同时覆盖两个字段的过滤。如果单独建两个单列索引,MySQL 一般只会选择其中一个,另一个字段的过滤就得回表,数据量大了性能差距很明显。
create_time 用 int(10) 时间戳而不是 datetime,是 TP 框架里比较常见的做法。排序和范围查询都走索引,PHP 端格式化输出即可。如果你更喜欢 datetime 的可读性,可以改,但要记得同步修改模型里的写入逻辑。
3.3 模型层与 Db 类:什么时候用模型,什么时候直接查
这套系统查询时同时用到了模型和 Db 类。区别在于模型适合封装业务逻辑,Db 类适合快速查询。比如前台列表页只需要取几条数据展示出来,直接上 Db 类:
// 前台列表查询 use think\Db; $list = Db::name('news') ->where('status', 2) ->where('category_id', $categoryId) ->order('create_time', 'desc') ->limit(20) ->select();代码逻辑很直白:查已发布状态、按栏目过滤、按创建时间倒序、取 20 条。这里的 status = 2 是硬编码的魔法数字,实际开发中建议在模型里定义常量,比如 News::STATUS_PUBLISHED,不然代码里到处是裸的数字,过两个月你自己都不知道 2 代表什么。
创建新闻、更新状态这类带业务逻辑的操作,推荐单独写一个 News 模型:
// app/model/News.php namespace app\model; use think\Model; class News extends Model { const STATUS_DRAFT = 0; const STATUS_PENDING = 1; const STATUS_PUBLISHED = 2; const STATUS_OFFLINE = 3; public function publish($id) { $news = $this->find($id); if (!$news || $news->status != self::STATUS_PENDING) { return false; } $news->status = self::STATUS_PUBLISHED; $news->save(); return true; } }这个封装的意义在于:所有状态流转的判断都收口在模型里,控制器只需要调用 publish 方法,不需要关心状态流转的细节。多个控制器调用同一套逻辑时,不会出现有的地方忘了判断待审核状态。
3.4 模型事件与过度设计的边界
ThinkPHP 模型支持 after_update、after_insert 等事件钩子,比如新闻审核通过后要更新栏目的统计字段、写操作日志,都可以挂在模型事件里。
但项目初期不建议过度设计。事件写多了整条业务链路会变得像黑匣子,数据莫名被修改时很难定位是哪段钩子触发的。我一般只把「状态变更 + 日志写入」这层逻辑放进模型,其他页面展示逻辑全部放在控制器里,保持直观。这套系统的代码风格也是这个路子,没有堆砌太多魔术逻辑,读起来比较舒服。
4. 前台页面与 UEditor 集成:富文本、图片上传和列表渲染
4.1 为什么选 UEditor:部署成本最低的富文本方案
前台页面本身结构简单,真正的重头戏在后台的富文本编辑器。市面上编辑器五花八门,百度 UEditor 虽然官方维护频率低,但依然是国内很多中小项目的首选——它不依赖 npm 构建,直接把静态资源丢到 public 目录就能跑,而且图片上传、视频上传的后端逻辑都有成套的 PHP 代码。对一套强调「易学易用」的 TP 资源来说,这是最对味的选择。
4.2 UEditor 的引入与服务端配置
把 UEditor 解压后放到 public/ueditor,后台发布页面的模板里引入三段资源:
<link rel="stylesheet" href="/ueditor/themes/default/css/ueditor.css"> <script src="/ueditor/ueditor.config.js"></script> <script src="/ueditor/ueditor.all.min.js"></script> <script> var ue = UE.getEditor('container'); </script>textarea 那边注意给个 id="container" 的空标签,UEditor 会自动把它替换成编辑区。ueditor.config.js 里可以设置编辑器高度、工具栏按钮等,默认配置一般够用,不需要动太多。
服务端上传配置在 UEditor 的 PHP 目录里,是一个 config.json 文件。图片上传路径、允许的扩展名、大小限制都在里面。开发时记得把图片访问路径的根目录设置成 /upload/ 这种公开可访问的地址,否则上传成功但是前端展示时读不到文件。
4.3 图片上传返回格式:最常见的翻车点
UEditor 的图片上传通过 controller.php 这个入口文件处理,它返回给前端的数据格式有严格规定:必须是 state、url、title、original 这几个字段的 JSON。很多人在这里翻车,是因为自定义上传接口返回了 {code:0, data:{url:...}} 这种常见接口格式,UEditor 不认,表现为编辑器能打开但点图片上传一直没反应,控制台报 parse error。
解决方法是把上传接口的返回值改成 UEditor 的格式:
// UEditor 后端返回格式 $result = [ 'state' => 'SUCCESS', 'url' => '/upload/20250101/xxxx.jpg', 'title' => 'xxxx.jpg', 'original'=> '原文件名.jpg' ]; echo json_encode($result);注意 state 字段必须是英文字符串 SUCCESS,且不能有别的字段混在里面。如果你给 UEditor 的 config.json 里配置了自定义上传地址,后端再按通用 JSON 格式返回,前端解析必挂。以后看到编辑器上传没反应,先开浏览器的 Network 面板看响应体,再对着 state 字段找问题,基本都是这个套路。
4.4 前台列表与详情页渲染
前台模板的渲染逻辑不复杂,控制器里查出数据,assign 给模板,模板用 foreach 循环输出:
// 控制器 public function index() { $categoryId = input('get.category_id', 0); $where = ['status' => 2]; if ($categoryId) { $where['category_id'] = $categoryId; } $list = Db::name('news') ->where($where) ->order('create_time', 'desc') ->paginate(10); $this->assign('list', $list); return $this->fetch(); }<!-- 模板列表页 --> <ul class="news-list"> {volist name="list" id="vo"} <li> <a href="/news/read/id/{$vo.id}">{$vo.title}</a> <span>{$vo.create_time|date="Y-m-d",###}</span> </li> {/volist} </ul> {$list|raw}paginate(10) 会自动生成分页对象,模板里 {$list|raw} 输出分页链接。这里有个易踩的坑:模板里的时间格式化用了 TP 自带的 date 模板修饰符,第三个参数 ### 表示把当前值作为时间戳传入。如果你改了数据库字段类型,比如把 create_time 改成 datetime,这里的格式化方式就要换成 {$vo.create_time|strtotime} 之类的转换,不然页面上显示的是 1970 年。
前台详情页逻辑更简单,按 id 查一条数据:
$news = Db::name('news')->where('id', $id)->where('status', 2)->find(); if (!$news) { $this->error('内容不存在或未发布'); } // 浏览量自增 Db::name('news')->where('id', $id)->setInc('views');浏览量的自增用 setInc 一步搞定,不需要先查再写。注意顺序:先查状态,再自增浏览量,避免把草稿的浏览量也加上去。
5. 后台管理实战与常见问题排查:登录认证、发布审核和踩坑记录
5.1 后台登录认证与 session 控制
后台所有的控制器都继承一个 AdminBase 基类,在初始化方法里统一判断登录状态。这样做的好处是权限判断收敛在一个地方,不用每个控制器里重复写判断逻辑。
// application/admin/controller/AdminBase.php namespace app\admin\controller; use think\Controller; class AdminBase extends Controller { protected function initialize() { if (!session('?admin_id')) { $this->redirect('admin/login/index'); } } }登录成功后写入 session,密码校验用的是 password_hash 和 password_verify 这对组合,比直接存 MD5 靠谱得多。这套系统如果你拿到源码,看 admin 登录控制器的逻辑就能看到完整的用法。
5.2 发布、编辑、审核的状态流转
后台的操作流程大致是:编辑创建新闻 → 保存草稿 → 提交审核(状态流转到待审核) → 管理员在列表页点击通过/驳回 → 通过后状态变为已发布,前台才可见。
状态流转如果散落在各个控制器方法的 if else 里,很容易出现漏改。你看下源码里的 Admin News 控制器,一般会封装一个 changeStatus 方法:
// 后台审核操作 public function audit() { $id = input('post.id'); $action = input('post.action'); // pass 或 reject if ($action == 'pass') { Db::name('news')->where('id', $id)->update([ 'status' => News::STATUS_PUBLISHED, 'update_time' => time() ]); } else { Db::name('news')->where('id', $id)->update([ 'status' => News::STATUS_DRAFT, 'update_time' => time() ]); } return json(['code' => 1, 'msg' => '操作成功']); }驳回退回草稿而不是直接删除,这个设计很关键,编辑可以继续修改再重新提交,避免有效内容被误删。
5.3 踩坑记录:这套系统最常见的五个问题
下面几条都是实际部署和二次开发中会高频遇到的问题,按现象、原因、解决的方式整理出来,每条都是付过学费的。
坑一:登录后台立刻被踢回登录页
现象:输入账号密码点登录,跳转后台首页后马上又跳回登录页,像死循环一样。
原因:session 没生效。最常见是 TP 的 session 配置里驱动选的是 file,但 runtime/session 目录没有写权限。另一种可能是服务器开了多个 PHP 进程,session 文件互相覆盖。
解决:把 runtime 目录权限设为 755 或 777,确认服务器能正常创建 session 文件。如果还不行,打开 config/session.php 把驱动改成 redis 试试,前提是装了 redis 扩展。
坑二:UEditor 图片上传后前台无法访问
现象:上传返回成功,但前台文章里的图片显示裂图,直接访问图片 URL 返回 404。
原因:Nginx 的 root 配置指向了 public 目录,而图片实际存放在 public/upload 下,按理论不应该 404,但如果 UEditor 的配置文件里图片保存路径写成了 /data/upload,文件被写到了 public 之外的目录,nginx 自然访问不到。
解决:打开 UEditor 的 config.json,把图片路径相关配置改成相对于 public 的路径,例如 /upload/image/。同时确认上传目录有写权限。
坑三:去掉了 index.php,页面全部 404
现象:伪静态配置完,访问首页正常,点击详情页链接 404,或者后台链接全部打不开。
原因:Nginx 伪静态规则只写了 location /,没有把 /admin 等后台路径也走 rewrite,或者 rewrite 规则顺序写错了。
解决:Nginx 的 rewrite 规则要放在 server 块中最外层,同时确认 public/.htaccess 文件存在。Apache 环境检查 mod_rewrite 是否开启。可以把 Nginx 配置改成下面这样,覆盖所有路径:
location / { if (-e $request_filename) { break; } rewrite ^(.*)$ /index.php?s=$1 last; }坑四:导入 SQL 时提示表已存在或者字段冲突
现象:第二次导入时报错,或者导入后页面报某个字段不存在。
原因:数据库里残留了同名表,尤其第一次导入失败后,MySQL 会自动把建了一半的表留在库里。
解决:导入前先 DROP 掉原有的表,或者用 phpMyAdmin 的「导入时清空已有表」选项。正规做法是重新建一个空数据库,然后一次性导入干净的 SQL。
坑五:修改了代码页面不生效
现象:改了控制器、模板文件,刷新页面还是老样子。
原因:TP 的模板缓存和 runtime 目录缓存没清。很多新手不知道 runtime 目录是干嘛的,改完代码不清理,就会看到一份「过期」的页面。
解决:手动删除 runtime/cache 和 runtime/temp 目录下的缓存文件,或者直接删掉整个 runtime 目录让它重建。本地开发时可以关闭模板缓存,在 config/template.php 里设置 'cache' => false,省得每次都要手工清。
5.4 权限与操作日志:小系统也要留一手
后台系统如果面向多人管理,至少要把操作日志做了。谁在什么时候审核了一篇文章、改动过什么字段,这些信息在出问题时是唯一的追溯线索。不需要设计太复杂的日志表,简单的操作记录表就够了:
CREATE TABLE `tp_operation_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `admin_id` int(11) NOT NULL, `action` varchar(100) NOT NULL, `target_id` int(11) NOT NULL, `detail` varchar(500) NOT NULL, `create_time` int(10) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;每次审核、删除操作调用一个公共的日志写入方法,把管理员 ID、操作类型、对象 ID 记下来,成本很低,收益很大。
6. 性能优化与部署技巧:慢查询、模板缓存与上线检查清单
后台跑通之后,收尾阶段有几个优化动作值得做一遍,尤其是上线前。
先开慢查询日志。TP 的数据库配置里有一个 'debug' => true 开关,开启后会在页面上输出 SQL 执行时间。不要只在出了问题才开,我一般开发最后一天会开着这个跑一遍所有页面,专门抓执行时间超过 100ms 的查询。新闻列表页如果数据量过万,没加索引的 where 条件会在日志里非常难看。
列表查询千万别用 select() 全量查,改成 paginate(10) 或者 limit(20),分页对用户体验影响很大。内容表查询建议加个缓存:
$list = Db::name('news') ->where('status', 2) ->cache('news_list_' . $categoryId, 60) ->select();cache 方法是 TP 内置的查询缓存,这里的意思是缓存 60 秒。新闻系统的更新频率不高,缓存 5 分钟都问题不大。注意缓存键别乱写,带上分类 ID 避免不同栏目串数据。
模板缓存那一层,开发期关掉,上线前打开。TP 的模板引擎默认开启缓存,你在 config/template.php 里确认一下缓存开启状态。关掉缓存的写法是:
// config/template.php 'cache' => false,但这个只适合本地改样式用,上线前务必改回 true,不然后台操作系统会自动清理模板缓存,虽然不影响功能,但无谓地增加 IO 开销。
上线前我习惯把快速检查清单跑一遍:关闭 app_debug、确认错误页面不暴露 SQL 信息、修改默认后台密码、删除 install 相关脚本、检查 runtime 目录不可被外部访问。这套系统默认的初始密码一定要在上线当天换掉,不是危言耸听,扫描器扫到默认密码是分分钟的事。
做完上面的优化和检查,这个新闻系统基本就能扛住日活几千量级的小站了。有一次交付后一周用户反馈图片加载变慢,排查下来是没给静态资源做缓存策略,加了一行 Nginx 配置解决了。从那以后我每次上线前都要强制走一遍静态资源过期时间的设置,顺手看看编辑器上传目录有没有不该在里面的文件。建站容易,守住和优化才是长期的事,希望这些经验能让你少走几步弯路,希望帮到你。
本文还有配套的精品资源,点击获取