简介:一套基于ThinkPHP框架的新闻发布系统源码与配套学习资料,面向PHP初学者和Web开发入门者,直观展示MVC分层思想、数据库操作以及前台展示与后台管理两大模块的协作方式,适合想从零搭建新闻网站、理解真实项目结构的读者。压缩包共1117个文件,主体为414个PHP脚本,辅以23个HTML页面、21个CSS样式、31个JavaScript脚本,以及UEditor富文本编辑器所需的503个GIF图标和图片上传、文件管理等服务端处理相关ashx文件;整体仅6.43MB,轻量易部署,目录结构清晰。目前已有4729人学习下载,学习热度可观。内容涵盖文章发布、分类管理、UEditor集成、前端分页搜索等完整流程,附SQL数据库文件、安装说明及后台管理示例;从数据库表设计、ActiveRecord模型操作,到控制器请求处理和模板引擎渲染,都有可直接运行的代码可供对照。脚本中还包含参数过滤、数据转义等安全处理,并预留缓存优化空间,可作为PHP项目实战与框架入门的参考。
1. 为什么说这套 ThinkPHP 新闻发布系统是真的易学易用
如果你接手的第一个完整 Web 项目就是要做一个新闻发布系统,那么 ThinkPHP 这套“前台+后台”方案,几乎是当前最不容易劝退人的选择。它把前台展示、后台管理和数据库操作拆成了标准 MVC 结构,控制器里几十行代码就能撑起一个列表页,模板里几个标签就能把数据渲染出来,完全不需要手工拼 SQL 拼 HTML。很多想做 PHP 开发的人,就是靠这种新闻系统跨过了“会语法但不会做项目”的坎。本文不做纸面介绍,直接带你从建环境、建表、写控制器,一路把前台列表、后台登录、新闻增删改查和文件上传全部跑通,顺便把新手最容易翻车的几个地方提前说透。这套东西也适合作为外包、毕设或公司内部信息发布的基础骨架,改一改就能接别的业务。
2. 从零跑通 ThinkPHP 新闻项目:环境准备与目录骨架
2.1 为什么选 ThinkPHP:MVC 思路与中文生态
动手前先想清楚选型。写新闻系统你当然可以用原生 PHP 从头写,但那样你会把大量时间耗在过滤参数、拼接 SQL、处理分页这些重复劳动上;你也可以用 Laravel,功能确实强,但对刚接触框架的人来说,命令行工具、服务容器、中间件概念叠在一起,很容易学着学着就放弃。ThinkPHP 则是把 MVC 思路用最直白的方式摆在你面前:浏览器请求某个 URL,路由找到对应控制器方法,控制器里取数据,模板负责渲染,整个过程一条线下来,非常符合正常人理解和排查问题的习惯。
我接触过不少从零学 PHP 的开发者,他们第一周能照着官方示例跑通一个 Hello World,第二周就卡在“我该怎么组织一堆文件”上。ThinkPHP 的项目结构把控制器、模型、视图、配置分成了固定目录,你要做的只是在里面填业务代码,而不是先设计一套架构。而且它的报错页面会直接告诉你“哪行代码、哪个文件、什么类型错误”,新手不会面对一个 500 空白的黑匣子。这套新闻系统选它的第二层原因是部署门槛低:只要一台能跑 PHP 的服务器就够了,不需要额外常驻进程,很多虚拟主机都能直接跑。
2.2 最小可运行环境与两条安装路径
先说环境。以当前常用版本为例,建议 PHP 版本不低于 7.4,正式做项目直接用 PHP 8 以上更好;数据库用 MySQL 5.7 或 8.0;Web 服务器用 Nginx 或 Apache 都行。这里有一个容易被新手忽略的点:PHP 需要开启 pdo_mysql 扩展,否则后面连数据库会直接报“找不到驱动”。
安装 ThinkPHP 最常见的做法是用 Composer 创建项目。打开命令行,进入你的 Web 根目录,执行:
composer create-project topthink/think tp_news cd tp_news如果下载速度很慢,先在全局配一个国内 Composer 镜像,再执行上面的命令,几分钟就能拿到完整项目骨架。另一个常见做法是直接从官网下载 Release 包解压,适合没有装 Composer 的临时环境。我个人的建议是:宁可花十分钟装 Composer,也别手工去补依赖,后面你如果要加验证码组件、图片处理库,Composer 一条命令就解决了,手工下载迟早出问题。
项目跑起来前,还要配置虚拟主机或 Nginx。最关键的一条是把站点根目录指向public,而不是项目根目录。ThinkPHP 的入口文件在public/index.php,这样做也是出于安全考虑:Web 只能访问 public 里的文件,项目里的应用代码、配置、日志都留在外面。Nginx 下常见配置是:
server { listen 80; server_name tpnews.local; root /var/www/tp_news/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }注意rewrite这行是关键:ThinkPHP 的 URL 走了伪静态,请求会经过入口文件解析路由。本地写 host 把tpnews.local指到 127.0.0.1,浏览器访问http://tpnews.local能看到默认欢迎页,就说明环境通了。
2.3 先认准项目里的三个关键目录与配置文件
环境通了,别急着写代码,先花几分钟把目录结构认清楚。以我们创建的tp_news项目为例,你后面 90% 的操作都集中在这几个地方:
tp_news/ ├─ app/ │ ├─ controller/ │ │ ├─ Index.php │ │ └─ admin/ │ ├─ model/ │ │ ├─ News.php │ │ └─ Category.php │ ├─ view/ │ │ ├─ index/ │ │ └─ admin/ │ └─ BaseController.php ├─ config/ │ ├─ app.php │ └─ database.php ├─ public/ │ ├─ index.php │ ├─ static/ │ └─ storage/ ├─ route/ │ └─ app.php └─ .envapp/controller放前台控制器,后台控制器我习惯在下面再建一个admin子目录,这样前后台代码从目录上就是分离的,模块边界很清楚。app/model放模型文件,News 模型对应新闻表,Category 模型对应分类表。app/view放模板,模板路径默认和控制器名、方法名对应,比如Index 控制器的 index 方法对应 app/view/index/index.html。route/app.php是路由定义文件,URL 地址和控制器方法之间的映射关系写在这里。config/database.php负责数据库连接配置。
数据库连接是最先要改的配置。打开.env或config/database.php,把数据库名、用户名和密码填对:
'database' => 'tp_news', 'username' => 'root', 'password' => 'your_password', 'hostname' => '127.0.0.1', 'hostport' => '3306', 'charset' => 'utf8mb4',charset我建议固定用utf8mb4,而不是老项目常见的utf8。utf8mb4 才能存 emoji 和生僻字,新闻内容里偶尔会有特殊符号,用 utf8 容易出现“保存成功但查出来乱码”。填完配置,下一步就是建数据表。
3. 新闻系统的数据基础:建表、模型与链式查询
3.1 三张核心表:字段设计思路与建表 SQL
新闻系统的数据结构并不复杂,但字段设计直接影响后面的开发效率。一个最实用的新闻系统只需要三张表:新闻表news、分类表category、管理员表admin。先说新闻表,字段就是标题、分类、正文、封面、状态、点击量、时间信息:
CREATE TABLE `news` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL DEFAULT '', `category_id` int(11) unsigned NOT NULL DEFAULT '0', `content` text NOT NULL, `cover` varchar(255) NOT NULL DEFAULT '', `status` tinyint(1) NOT NULL DEFAULT '1', `views` int(11) unsigned NOT NULL DEFAULT '0', `create_time` int(11) NOT NULL DEFAULT '0', `update_time` int(11) NOT NULL DEFAULT '0', `delete_time` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_status_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段设计都是有原因的。title定成 varchar(100),一般新闻标题足够用,太长是浪费索引空间。status用 tinyint,1 表示发布、0 表示下架,将来做“草稿箱”功能也能扩展。views是点击量,做阅读数排行时直接ORDER BY views就能查。create_time和update_time我用的是整型时间戳,ThinkPHP 默认就能自动写入,比 datetime 类型更适合程序处理和时区换算。delete_time默认是 NULL,这就是软删除的标记,查询时框架会自动给你加上delete_time IS NULL条件。
分类表和管理员表相对简单:
CREATE TABLE `category` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL DEFAULT '', `sort` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `admin` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL DEFAULT '', `password` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;admin表的password字段特意留成 varchar(255),因为存的是password_hash()生成的安全散列值,而不是明文。很多新手直接存明文密码,这是后台系统里最致命的安全漏洞,后面登录那章我会写出来正确做法。
3.2 模型定义:关联、时间戳与软删除
表建好了,接下来在app/model里建立对应模型。ThinkPHP 的模型类不只是“一张表的映射”,它还能帮你自动处理时间戳、软删除和表关联。News 模型的完整写法:
<?php namespace app\model; use think\Model; use think\model\concern\SoftDelete; class News extends Model { use SoftDelete; protected $name = 'news'; protected $autoWriteTimestamp = true; protected $createTime = 'create_time'; protected $updateTime = 'update_time'; protected $deleteTime = 'delete_time'; public function category() { return $this->belongsTo(Category::class, 'category_id'); } }这里use SoftDelete是开启软删除特性,$deleteTime指定软删除字段。$autoWriteTimestamp = true表示新增、更新时自动写入时间戳。category()方法定义了与分类模型的关联关系:新闻表里的category_id指向分类表id,所以用belongsTo。看名字也能理解——每条新闻属于一个分类。
Category 模型可以精简一些:
<?php namespace app\model; use think\Model; class Category extends Model { protected $name = 'category'; public function news() { return $this->hasMany(News::class, 'category_id'); } }hasMany是反向定义:一个分类下有多条新闻。关联方法不需要手动写 SQL,后面查询直接用with(['category'])就能把新闻和分类一次性查出来,避免 N+1 问题。这里要提醒一句:如果建表时你没用create_time这些标准字段名,而是用了created_at,那$createTime也要改成对应字段名,否则时间戳不写入,你会看到新增的数据时间字段全是 0。
3.3 链式查询:把列表数据一次拼装好
模型定义完,真正的查询套路就来了。ThinkPHP 的链式查询用起来很像是在“说人话”,每个方法就是查询的一个条件,按顺序接在后面。前台首页要显示已发布的新闻,按发布时间倒序,带出分类名,每页 10 条:
$list = News::with(['category']) ->where('status', 1) ->order('create_time', 'desc') ->paginate(10);这一句干了四件事。with(['category'])预载入关联分类,执行时为避免不了要连表,但 ThinkPHP 会先把新闻查出来,再按 category_id 批量查分类,而不是每条新闻都执行一次分类查询。where('status', 1)只取已发布内容。order('create_time', 'desc')控制排序,注意是desc不是desc(),写错了会报方法不存在。paginate(10)返回一个分页对象,里面不仅有当前页数据,还带着“上一页、下一页、总页数”这些信息。
很多时候列表页还需要统计点击量,或者按分类筛选,查询条件会更多。不要每次都从零写,我习惯把通用条件抽成一个私有方法:
private function baseQuery() { return News::with(['category'])->where('status', 1); } public function index() { $list = $this->baseQuery() ->order('create_time', 'desc') ->paginate(10); return view('index', ['list' => $list]); }这样做的好处是列表页、搜索页共用同一套基础条件,以后如果要加“仅显示置顶新闻”,只改一处就够了。另一个常见坑是paginate()和select()别混:select()返回的是纯数组/模型集合,没有分页方法,模板里直接{$list|raw}会报错;只有paginate()返回的对象才带render()。前台的列表、详情、分页到底怎么写,下一章展开。
4. 前台三件套:列表、详情、分页与搜索
4.1 前台控制器:列表与详情的标准写法
前台控制器我放在app/controller/Index.php,对应网站首页和新闻详情。先看列表方法和详情方法的完整代码:
<?php namespace app\controller; use app\BaseController; use app\model\News; use think\facade\Request; class Index extends BaseController { public function index() { $list = News::with(['category']) ->where('status', 1) ->order('create_time', 'desc') ->paginate(10); $this->assign('list', $list); return $this->fetch(); } public function detail($id) { $info = News::with(['category']) ->where('id', $id) ->where('status', 1) ->find(); if (!$info) { abort(404, '新闻不存在'); } News::where('id', $id)->inc('views')->update(); $this->assign('info', $info); return $this->fetch(); } }detail($id)里的$id是路由传过来的参数,ThinkPHP 会自动绑定到方法参数上。find()查不到数据时返回null,所以做了!$info判断并调用abort(404)给出友好提示,避免模板里访问不存在的对象报错。阅读数自增用了inc('views'),这是框架提供的原子递增方法,比先find再views+1再save靠谱,并发下不会丢数据。
assign和fetch是控制器与模板之间的桥梁。assign('info', $info)把数据传给模板,模板里用$info就是这条新闻;fetch()默认找app/view/index/detail.html这个模板文件,不需要手动写模板路径,默认映射机制就能对上。
4.2 模板渲染:从变量赋值到分页控件
模板是这套系统里最容易看出“易学易用”的地方。ThinkPHP 的模板语法没有引入太复杂的概念,HTML 里嵌一点循环标签就能干活。列表页的核心模板:
<div class="news-list"> {volist name="list" id="item"} <div class="news-item"> <h2><a href="/index/detail/id/{$item.id}">{$item.title}</a></h2> <p class="meta"> 分类:{$item.category.name|default='未分类'} | 点击:{$item.views} </p> </div> {/volist} </div> <div class="page-nav"> {$list|raw} </div>{volist name="list" id="item"}相当于 foreach,name是控制器 assign 过来的变量名,id是循环里的当前元素。{$item.title}输出标题,模板引擎默认做了 HTML 转义,即便标题里有<script>标签也会被转成普通文本,这是防 XSS 的基础保障。{$item.category.name}能直接点出一层关联数据,前提是控制器里用了with(['category'])预载入,否则这里要额外查库,页面会变慢。{$item.category.name|default='未分类'}是模板过滤器的用法,意思是如果分类名为空就显示“未分类”,新闻没选分类时就不会白屏报错。
分页输出这里有个新手经典坑:直接写{$list|raw}还是{$list->render()|raw},取决于框架版本和 Paginator 输出方式。我习惯在控制器里先把渲染好的分页字符串单独 assign 出来,模板只用变量,不关心内部实现。比如控制器里再加一行:
$this->assign('pages', $list->render());模板这边就变成:
<div class="page-nav"> {$pages|raw} </div>$list->render()返回的是一段 HTML,|raw告诉模板引擎“这段内容不要转义,直接输出”。分页的样式默认很朴素,如果你用的是 Bootstrap 或 Layui,需要去分页类里配置list_rows模板属性才能生成带对应 class 的分页导航,做法不太统一,建议直接参照 UI 组件的手动 CSS 覆盖。分页要能用远古条件去调试,后面避坑章细说。
4.3 分类筛选与关键词搜索:参数传递的坑
新闻列表一般还会有两个入口:按分类看某一类新闻、按关键词搜索标题。这两个功能都不需要新写一套逻辑,在基础查询上追加条件即可。控制器里我这样组织:
public function index() { $cid = (int) Request::param('cid', 0); $kw = trim(Request::param('kw', '')); $query = News::with(['category'])->where('status', 1); if ($cid > 0) { $query->where('category_id', $cid); } if ($kw !== '') { $query->where('title', 'like', "%{$kw}%"); } $list = $query->order('create_time', 'desc') ->paginate([ 'list_rows' => 10, 'query' => Request::param(), ]); $this->assign('list', $list); $this->assign('pages', $list->render()); return $this->fetch(); }关键在这一行:'query' => Request::param()。它的作用是把当前 URL 里的cid、kw参数原样带到分页链接里。如果不加,翻页链接就会变成/index/index?page=2,分类和搜索条件全部丢失,用户点了第二页就“跳出”当前筛选结果。$cid前面加了(int)强制转整数,是为了防止有人把cid传成字符串去干扰 SQL 拼接;搜索词用了trim去掉首尾空格,不然kw= php查出来一片空。
前台模板里搜索框与分类下拉需要把当前选中状态回填上,否则用户一刷新就不知道自己选了哪个分类。模板做法是:
<form action="/index/index" method="get"> <select name="cid"> <option value="0">全部分类</option> {volist name="categories" id="cat"} <option value="{$cat.id}" {eq name="cid" value="$cat.id"}selected{/eq}>{$cat.name}</option> {/volist} </select> <input type="text" name="kw" value="{$kw|default=''}" placeholder="搜索标题"> <button type="submit">搜索</button> </form>{eq name="cid" value="$cat.id"}是模板里的比较判断,满足条件就输出selected。注意value="{$kw|default=''}"里因为$kw可能未定义,使用default过滤器兜底。分类下拉数据要单独查一遍分类表并 assign 过来,看分类筛选功能时不能只查新闻表,所有分类都要列出来给用户选。
5. 后台登录与新闻管理:从请求到入库的完整链路
5.1 后台登录:密码校验、会话与退出
后台是整个新闻系统的管理端,第一道门就是登录。一个合格的登录要做到三件事:密码不能明文存储、登录状态要用 Session 记录、所有后台操作先过鉴权。管理员表里password字段存的是password_hash()生成的散列,那校验就该用password_verify()。看登录控制器:
<?php namespace app\controller\admin; use app\BaseController; use app\model\Admin; use think\facade\Request; use think\facade\Session; class Login extends BaseController { public function index() { return $this->fetch(); } public function doLogin() { $username = trim(Request::param('username', '')); $password = Request::param('password', ''); $admin = Admin::where('username', $username)->find(); if ($admin && password_verify($password, $admin->password)) { Session::set('admin_id', $admin->id); Session::set('admin_name', $admin->username); return redirect('/admin/news/index'); } return '用户名或密码错误'; } public function logout() { Session::delete('admin_id'); Session::delete('admin_name'); return redirect('/admin/login'); } }登录成功后只把admin_id和admin_name放进 Session,不要存整个管理员对象。Session 存在服务端,你可以把 admin_id 拿出来后随时再查库拿最新数据。登出就是删 Session,然后跳回登录页。很多项目在这时候漏掉一个细节:用户改了密码,原来登录态依然有效。要处理这个问题,登录后的后续操作里每次比对一下 Session 里的管理员信息跟数据库是否一致,但要注意别频繁查库,我一般会在改密码的请求里把对应 Session 清掉。
密码散列的生成方式也要说清楚,建议用 PHP 自带函数:
$hash = password_hash('你的初始密码', PASSWORD_DEFAULT);生成一次后直接 UPDATE 到 admin 表。PASSWORD_DEFAULT在不同 PHP 版本下算法可能不同,但password_verify()不受影响,它能兼容历史版本产生的散列。注意不要自己拼 MD5 加盐,MD5 的碰撞风险已经被验证过多次,password_hash 才是现在标配的安全做法。
5.2 新闻新增与编辑:数据校验和入库
后台核心操作是新闻的增删改查。新增和编辑共用一个表单,靠隐藏字段区分是“创建”还是“更新”。我先说新增的保存逻辑:
public function save() { $data = Request::only([ 'title', 'category_id', 'content', 'status', 'cover' ]); $validate = (new \think\Validate()) ->rule('title', 'require|max:100') ->rule('content', 'require'); if (!$validate->check($data)) { return '标题必填且不超过100字,内容必填'; } if ((int) $data['category_id'] <= 0) { return '请选择分类'; } $news = News::create($data); return redirect('/admin/news/edit/id/' . $news->id); }Request::only()只取表单里指定的几个字段,避免用户恶意提交额外参数覆盖你不想被改的字段。校验规则写在业务层而不是数据库层,是因为有些校验必须结合业务才能判断,比如“分类 ID 必须存在”。News::create($data)走模型创建,如果模型里配置了自动时间戳,create_time 和 update_time 会自己写入。
编辑和新增的区别仅仅是多了 id 和 update:
public function update() { $id = (int) Request::param('id', 0); $news = News::find($id); if (!$news) { return '新闻不存在'; } $data = Request::only([ 'title', 'category_id', 'content', 'status', 'cover' ]); $news->save($data); return redirect('/admin/news/edit/id/' . $id); }这里要提醒一个非常隐蔽的坑:模型创建或更新时,如果数据表字段很多,而你又没在模型里定义字段允许列表的话,ThinkPHP 的严格字段检测可能把一些字段过滤掉。表现就是表单里明明传了cover,入库后却是空字符串。解决办法是在模型里加一句声明:
protected $schema = [ 'id' => 'int', 'title' => 'string', 'category_id' => 'int', 'content' => 'string', 'cover' => 'string', 'status' => 'int', 'views' => 'int', ];这样模型就明确知道哪些字段是自己管理的,create和save才会完整保留这些数据。这个坑我在给某公司做内部信息台时踩过,排查了半个下午,最后就是差这么一行声明。
5.3 图片上传与中间件保护后台路由
新闻封面几乎都是必有的功能。ThinkPHP 的上传很简单,但安全和路径两个问题必须处理。控制器里的上传方法:
public function upload() { $file = Request::file('cover'); try { $file->validate([ 'size' => 2097152, 'ext' => 'jpg,jpeg,png,gif,webp' ]); $savename = \think\facade\Filesystem::disk('public') ->putFile('news_cover', $file); return json(['code' => 1, 'url' => '/storage/' . $savename]); } catch (\think\exception\ValidateException $e) { return json(['code' => 0, 'msg' => $e->getMessage()]); } }size单位是字节,2097152就是 2MB。这个限制建议别放太大,新闻封面图 2MB 以内足够,限制大小能避免有人传超大文件把磁盘塞满。ext严格指定允许的扩展名,只信任图片类型可以让上传入口更安全。Filesystem::disk('public')->putFile()会把文件存到public/storage目录并生成随机文件名,随机名是为了避免文件名冲突和路径猜测。/storage/...这个 URL 直接拼在表单的cover字段里用。
表单那边记得加上enctype="multipart/form-data":
<form action="/admin/news/save" method="post" enctype="multipart/form-data"> <input type="text" name="title"> <select name="category_id"> ... </select> <input type="file" name="cover"> <textarea name="content"></textarea> <button type="submit">提交</button> </form>如果你忘了这行,PHP 会收不到文件,上传功能表现为“一直没反应”,因为文件根本没发到服务端。
后台所有路由都要保护起来,不能让人没登录就能直接访问/admin/news/delete把数据删光。最常见的做法是路由分组加中间件。在route/app.php:
use think\facade\Route; Route::group('admin', function () { Route::get('login', 'admin.Login/index'); Route::post('login', 'admin.Login/doLogin'); Route::get('logout', 'admin.Login/logout'); Route::get('news/index', 'admin.News/index'); Route::get('news/edit/:id', 'admin.News/edit'); Route::post('news/save', 'admin.News/save'); Route::post('news/update', 'admin.News/update'); Route::post('news/delete', 'admin.News/delete'); })->middleware(\app\middleware\AdminAuth::class);中间件类写在app/middleware/AdminAuth.php:
<?php namespace app\middleware; use think\facade\Session; class AdminAuth { public function handle($request, \Closure $next) { if (!Session::has('admin_id')) { return redirect('/admin/login'); } return $next($request); } }这里的关键是登录路由/admin/login也在这个分组里,但它不能要求登录。两种处理方式:一种是中间件里判断当前请求地址是admin/login就放行;另一种是把登录路由拆出来,不放进这个分组。我推荐后者,路由规则更清晰,不会在中间件里写死地址判断。中间件作用在分组上,分组内所有路由先过中间件,Session 没登录就弹回登录页,进不了新闻管理。
6. 常见问题排查与进阶验证:交付前的三个自查习惯
分页链接样式失效或参数丢失。现象:列表页第一页正常,点第二页要么样式全没了,要么分类搜索条件没了。原因:分页 HTML 里没有引入对应 CSS;或者paginate()没传query参数,翻页链接丢掉了cid、kw。解决:模板里引入 Bootstrap 或 Layui 的分页样式;控制器分页数组里加'query' => Request::param()。
模型时间戳字段不写值。现象:新增新闻后create_time是 0,排序乱套。原因:项目用了非默认字段名,比如数据表里叫created_at,而模型里没有声明$createTime。解决:模型加一行protected $createTime = 'created_at';,同时$updateTime也对应改。如果你不希望个别字段自动维护,就设成false。
上传大图报错或文件不落盘。现象:前端上传转圈后提示失败,后端日志有 Upload Error。原因:php.ini的upload_max_filesize默认只有 2M;或表单没有enctype="multipart/form-data"。解决:改php.ini里upload_max_filesize = 20M、post_max_size = 20M并重启 PHP;确认表单属性写全。改完用phpinfo()验证是否生效。
交付前我只做三件事。第一步,未登录状态直接访问后台新闻编辑页,确认会被踢回登录页;第二步,后台新增一条标题带<script>的新闻,去前台看它被转义成普通文本,而不是执行弹窗;第三步,把新闻 status 改成 0,确认前台不再展示。这三关过了,系统才算能交。数据库那边,若数据量上万,给news表加一个(status, create_time)的组合索引,列表查询翻页速度会明显提升。详情页可以做一层简单缓存,cache('news_' . $id, $info, 3600),发布或编辑时再cache('news_' . $id, null)清掉,新闻站阅读量大时能省不少数据库压力。我自己的习惯是每个版本交付前把 debug 关掉、开启日志记录,跑一遍增删改查加登录登出再放手。每一个看似“简单”的新闻系统,细节处都有不少需要打磨的地方,这套流程至少能让你少走两个月的弯路,希望帮到你。
本文还有配套的精品资源,点击获取