第10章 10.1 ThinkPHP 6 实战 CMS

「上章回顾」 上一章我们折腾完了 Swoole,用协程让 PHP 跑出了「异步特效」——一个请求里同时调用三个接口,不用傻等。但光会异步还不够,我们手里缺一个能快速搭后台的神器。这就好比你学会了炒菜技巧,但还没一套顺手的锅具。今天就给你一套ThinkPHP 6,用它搭 CMS 内容管理系统,就像搭积木一样快。


🎯 开场 3 分钟:为什么要学 ThinkPHP?

场景来了:

你接了个小活儿——给朋友的奶茶店做个后台,管商品、管订单、管会员。纯原生写光数据库增删改查就得三四天,还不算登录权限那套。朋友催得紧,你熬了两晚发现:大部分代码都是重复的——增删改查、列表分页、表单验证,这些套路每张表都差不多。

痛点就两个:

  1. 重复劳动太多——写 10 张表的后台,每张都要写「列表 + 新增 + 编辑 + 删除」,烦不烦?
  2. 想加点公共功能(比如权限控制、操作日志)得改一堆文件,头大不头大?

ThinkPHP 6 就是来解决这个\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n的。它帮你把通用的后台功能封装好,你只需要专注于「这张表有哪些字段」「列表要显示什么」——剩下的,自动帮你生成。

学完这章你能:

  • 用 ThinkPHP 6 搭建一个带文章管理的 CMS 后台
  • 掌握「模型 - 控制器 - 视图」的开发模式
  • 独立写出一个能跑起来的「增删改查」后台页面

🧱 基础 25 分钟:核心概念(小白视角)

10.1.1 ThinkPHP 6 是个啥?

类比时间: ThinkPHP 就像一个装修公司提供的「标准套餐」

  • 你想装修房子,可以自己买水泥、沙子、瓷砖慢慢贴(原生 PHP)
  • 也可以让装修公司按套餐来——墙纸、地板、门窗都是现成的标准件,只需要告诉它们「我要三室一厅、客厅要浅色地板」(ThinkPHP 规定好的开发模式)

为什么用它?

  • 开发快:80% 的后台需求,它已经给你搭好了
  • 社区成熟:遇到问题百度一搜一大把
  • 文档中文:官网文档全是中文,看得懂

10.1.2 安装 ThinkPHP 6

前置条件: PHP 7.4+(推荐 8.0+),Composer(PHP 的包管理工具)

打开命令行,一行命令搞定:

composer create-project topthink/think cms-demo

解释:这行命令让 Composer 去下载 ThinkPHP 6 的官方项目骨架,在当前目录创建一个叫 cms-demo 的文件夹。

cd cms-demo
php think run

解释:php think run 是 ThinkPHP 内置的调试服务器启动命令。运行后访问 http://localhost:8000,看到欢迎页就说明安装成功了。

10.1.3 目录结构:代码放哪儿?

ThinkPHP 6 的目录很讲究,约定了不同类型的文件放不同地方

cms-demo/
├── app/
│   ├── controller/     # 控制器:接收请求,调度数据
│   ├── model/          # 模型:操作数据库
│   ├── view/           # 视图:显示页面(HTML)
│   └── ...
├── route/              # 路由:URL 规则
├── public/             # 对外访问的入口
└── ...

类比: 就像一个餐厅分前厅(view)、后厨(controller)、仓库(model)。

10.1.4 第一个页面:Hello ThinkPHP

打开 app/controller/Index.php,改成这样:

<?php
namespace app\controller;

use app\BaseController;

class Index extends BaseController
{
public function index()
{
    return "Hello,ThinkPHP 6!我叫小明的奶茶店后台";
}
}

解释:
- namespace app\controller — 声明这个文件属于「控制器」阵营
- class Index — 创建了一个叫 Index 的控制器类
- public function index() — 访问首页时执行的函数
- return "..." — 直接返回一段文字

访问 http://localhost:8000,页面显示:Hello,ThinkPHP 6!我叫小明的奶茶店后台

这就跑起来了,比原生 PHP 少写了一大堆 includeecho

10.1.5 数据库操作:模型登场

光返回文字没用,CMS 得读写数据库。ThinkPHP 6 用模型来操作数据库。

先创建数据表(MySQL):

CREATE TABLE `article` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`content` text,
`status` tinyint(1) DEFAULT 1,
`create_time` int(10) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

创建模型 app/model/Article.php

<?php
namespace app\model;

use think\Model;

class Article extends Model
{
// 指定表名(如果不写,默认用类名小写)
protected $name = 'article';

// 自动写入时间戳
protected $autoWriteTimestamp = true;
}

解释:
- extends Model — 让 Article 类继承 ThinkPHP 的 Model,它自带一堆数据库操作方法
- $name = 'article' — 告诉模型「你要操作的是 article 表」
- $autoWriteTimestamp = true — 自动管理创建时间字段

10.1.6 控制器调用模型

修改 app/controller/Index.php

<?php
namespace app\controller;

use app\BaseController;
use app\model\Article;

class Index extends BaseController
{
public function index()
{
    // 查询所有文章
    $list = Article::select();

    return json([
        'code' => 0,
        'msg'  => '查询成功',
        'data' => $list
    ]);
}
}

解释:
- Article::select() — 模型自带的方法,查询 article 表所有记录,返回一个集合
- return json([...]) — ThinkPHP 自动把数组转成 JSON 返回

访问 http://localhost:8000,输出:

{"code":0,"msg":"查询成功","data":[]}

data 是空数组,因为表里还没数据。增删改查四件套马上安排。

10.1.7 增删改查四件套

ThinkPHP 6 的增删改查,比写 SQL 语句还简单。在控制器里加几个方法:

<?php
namespace app\controller;

use app\BaseController;
use app\model\Article;

class ArticleController extends BaseController
{
// 文章列表
public function index()
{
    $list = Article::order('id', 'desc')->select();
    return json(['code' => 0, 'data' => $list]);
}

// 新增文章
public function save()
{
    $data = request()->param();  // 获取前端传来的数据
    $result = Article::create($data);  // 插入数据库
    return json(['code' => 0, 'msg' => '新增成功', 'data' => $result]);
}

// 更新文章
public function update()
{
    $id = request()->param('id');
    $data = request()->param();
    $result = Article::update($data, ['id' => $id]);
    return json(['code' => 0, 'msg' => '更新成功', 'data' => $result]);
}

// 删除文章
public function delete()
{
    $id = request()->param('id');
    Article::destroy($id);
    return json(['code' => 0, 'msg' => '删除成功']);
}
}

一行一解释:

  • Article::order('id', 'desc')->select() — 查询并按 ID 倒序排列(最新的在前面)
  • request()->param() — 获取请求参数(GET/POST 都行)
  • Article::create($data) — 创建一条新记录
  • Article::update($data, ['id' => $id]) — 根据条件更新记录
  • Article::destroy($id) — 删除指定 ID 的记录

这就是 ThinkPHP 6 的核心——模型帮你封装好了 SQL,你只管调方法。

10.1.8 路由:URL 怎么对应到代码?

刚才写了控制器方法,但浏览器访问哪个 URL 能调到它?这就靠路由

打开 route/app.php(如果没有就创建),添加:

<?php
use think\facade\Route;

Route::get('article', 'article.ArticleController/index');   // 列表
Route::post('article', 'article.ArticleController/save');   // 新增
Route::put('article/:id', 'article.ArticleController/update'); // 更新
Route::delete('article/:id', 'article.ArticleController/delete'); // 删除

解释:
- Route::get/post/put/delete — 声明 HTTP 方法
- 'article' — URL 路径
- 'article.ArticleController/index' — 对应的「控制器/方法」

现在访问 http://localhost:8000/article,就能调到你写的列表方法了。


🔥 实战 35 分钟:3 个递进的小项目

项目 1:5 分钟搞定文章列表接口(抄改版)

目标: 写一个接口,返回文章列表(带分页)

完整代码:

app/controller/ArticleController.php

<?php
namespace app\controller;

use app\BaseController;
use app\model\Article;

class ArticleController extends BaseController
{
public function index()
{
    // 每页 10 条,获取当前页码(默认第 1 页)
    $page = request()->param('page', 1);
    $size = 10;

    // 计算偏移量
    $offset = ($page - 1) * $size;

    // 查询总数
    $total = Article::count();

    // 查询当前页数据
    $list = Article::order('id', 'desc')
        ->limit($offset, $size)
        ->select();

    return json([
        'code' => 0,
        'msg'  => 'success',
        'data' => [
            'list'  => $list,
            'total' => $total,
            'page'  => $page,
            'size'  => $size
        ]
    ]);
}
}

路由 route/app.php

Route::get('api/article', 'article.ArticleController/index');

预期输出:

访问 http://localhost:8000/api/article?page=1

{
"code": 0,
"msg": "success",
"data": {
    "list": [],
    "total": 0,
    "page": 1,
    "size": 10
}
}

一句话解释: 先算偏移量,再查总数,最后查当前页数据——这就是经典的「SQL 偏移分页」。


项目 2:15 分钟完成文章「增删改查」完整功能

目标: 做一个带后台管理页面的文章管理功能(前后端结合)

Step 1:创建视图文件

app/view/article/ 目录下创建 index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>文章管理 - CMS 后台</title>
<style>
    body { font-family: Arial, sans-serif; margin: 20px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
    th { background-color: #4CAF50; color: white; }
    .btn { padding: 5px 10px; margin: 2px; cursor: pointer; }
    .btn-edit { background-color: #2196F3; color: white; border: none; }
    .btn-del { background-color: #f44336; color: white; border: none; }
    .form-group { margin: 10px 0; }
    input, textarea { padding: 8px; width: 300px; }
</style>
</head>
<body>
<h1>📝 文章管理</h1>

<!-- 新增表单 -->
<div class="form-group">
    <input type="text" id="title" placeholder="文章标题">
    <textarea id="content" placeholder="文章内容"></textarea>
    <button class="btn btn-edit" onclick="saveArticle()">发布文章</button>
</div>

<hr>

<!-- 文章列表 -->
<table>
    <thead>
        <tr>
            <th>ID</th>
            <th>标题</th>
            <th>内容摘要</th>
            <th>创建时间</th>
            <th>操作</th>
        </tr>
    </thead>
    <tbody id="articleList"></tbody>
</table>

<script>
    // 加载文章列表
    function loadList() {
        fetch('/api/article')
            .then(res => res.json())
            .then(data => {
                let html = '';
                data.data.list.forEach(article => {
                    html += `
                        <tr>
                            <td>${article.id}</td>
                            <td>${article.title}</td>
                            <td>${article.content ? article.content.substring(0, 50) + '...' : ''}</td>
                            <td>${new Date(article.create_time * 1000).toLocaleString()}</td>
                            <td>
                                <button class="btn btn-del" onclick="deleteArticle(${article.id})">删除</button>
                            </td>
                        </tr>
                    `;
                });
                document.getElementById('articleList').innerHTML = html || '<tr><td colspan="5">暂无文章</td></tr>';
            });
    }

    // 新增文章
    function saveArticle() {
        const title = document.getElementById('title').value;
        const content = document.getElementById('content').value;

        fetch('/api/article', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ title, content })
        })
        .then(res => res.json())
        .then(data => {
            alert(data.msg);
            if (data.code === 0) {
                document.getElementById('title').value = '';
                document.getElementById('content').value = '';
                loadList();
            }
        });
    }

    // 删除文章
    function deleteArticle(id) {
        if (!confirm('确定删除这篇文章?')) return;

        fetch('/api/article/' + id, { method: 'DELETE' })
            .then(res => res.json())
            .then(data => {
                alert(data.msg);
                loadList();
            });
    }

    // 页面加载时读取列表
    loadList();
</script>
</body>
</html>

Step 2:添加控制器方法

ArticleController.php 添加两个方法:

// 渲染后台页面
public function admin()
{
return view('article/index');
}

// API:新增
public function save()
{
$data = request()->param();
$result = Article::create($data);
return json(['code' => 0, 'msg' => '新增成功', 'data' => $result]);
}

// API:删除
public function delete()
{
$id = request()->param('id');
Article::destroy($id);
return json(['code' => 0, 'msg' => '删除成功']);
}

Step 3:配置路由

// 后台页面
Route::get('admin/article', 'article.ArticleController/admin');

// API 接口
Route::post('api/article', 'article.ArticleController/save');
Route::delete('api/article/:id', 'article.ArticleController/delete');

预期输出:

访问 http://localhost:8000/admin/article,你会看到一个带表单的文章管理页面:
- 输入标题和内容,点击「发布文章」会调用 API 新增
- 下方列表显示已发布的文章,每行有「删除」按钮

一句话解释: 控制器负责返回 HTML 页面,前端 JS 调用后端 API 实现「增删」——前后端分离的好处就出来了。


项目 3:15 分钟做一个「数据导入导出」小工具

目标: 把文章数据导出成 CSV,再支持导入 CSV 补充数据

导出 CSV 接口

// API:导出文章为 CSV
public function export()
{
$list = Article::order('id', 'desc')->select();

// 设置 CSV 表头
$header = ['ID', '标题', '内容', '创建时间'];

// 构建 CSV 内容
$csvData = [];
$csvData[] = $header;
foreach ($list as $article) {
    $csvData[] = [
        $article->id,
        $article->title,
        $article->content,
        date('Y-m-d H:i:s', $article->create_time)
    ];
}

// 生成 CSV 字符串
$str = "";
foreach ($csvData as $row) {
    $str .= implode(",", $row) . "\n";
}

// 输出文件(浏览器下载)
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename=article_' . date('Ymd') . '.csv');
echo $str;
exit;
}

导入 CSV 接口

// API:导入 CSV
public function import()
{
$file = request()->file('file');  // 获取上传的文件

if (!$file) {
    return json(['code' => 1, 'msg' => '请选择文件']);
}

// 验证并保存文件
$info = $file->rule('uniqid')->move('./uploads/');

if (!$info) {
    return json(['code' => 1, 'msg' => '上传失败']);
}

// 读取 CSV 文件
$filePath = './uploads/' . $info->getFilename();
$handle = fopen($filePath, 'r');

$count = 0;
fgetcsv($handle);  // 跳过表头

while (($data = fgetcsv($handle)) !== false) {
    // 跳过空行
    if (empty($data[0])) continue;

    // 批量插入(每 100 条插入一次)
    if ($count % 100 == 0) {
        $batchData = [];
    }

    $batchData[] = [
        'title'      => $data[1],
        'content'    => $data[2],
        'create_time'=> time()
    ];

    if (count($batchData) >= 100) {
        (new Article)->insertAll($batchData);
        $batchData = [];
    }

    $count++;
}

// 插入剩余数据
if (!empty($batchData)) {
    (new Article)->insertAll($batchData);
}

fclose($handle);

return json(['code' => 0, 'msg' => '导入成功,共导入 ' . $count . ' 条数据']);
}

路由

Route::get('api/article/export', 'article.ArticleController/export');
Route::post('api/article/import', 'article.ArticleController/import');

预期输出:

导出:http://localhost:8000/api/article/export — 浏览器下载 article_20250626.csv

导入:用 Postman 或前端表单上传 CSV 文件,返回 {"code":0,"msg":"导入成功,共导入 50 条数据"}

一句话解释: 导出用 fputcsv 生成文件流,导入用 fgetcsv 逐行读取再批量写入——CSV 批量处理的核心就这两招。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:模型名和表名对不上

❌ 错误示例:

// 模型类名叫 Article,但表名叫 "articles"(多了个 s)
class Article extends Model
{
// 没指定表名,默认找 "article" 表,但实际是 "articles"
}

✅ 正确写法:

class Article extends Model
{
protected $name = 'articles';  // 明确指定表名
}

坑来了: 如果表名和模型名不符合 ThinkPHP 的默认规则,必须手动指定,否则查出来的数据是空的。


坑 2:时间戳字段没自动写入

❌ 错误示例:

// 插入数据后发现 create_time 是空的
$data = ['title' => '测试', 'content' => '内容'];
Article::create($data);

✅ 正确写法:

class Article extends Model
{
protected $autoWriteTimestamp = true;  // 开启自动时间戳
// 或者指定字段类型
// protected $autoWriteTimestamp = 'datetime';
}

坑来了: ThinkPHP 的自动时间戳只在你声明 $autoWriteTimestamp = true 后才生效,否则不会自动填充。


坑 3:路由不生效?

❌ 错误示例:

路由写了,但访问 404。检查了半天发现——没开启多应用模式

✅ 正确做法:

ThinkPHP 6 默认是单应用模式,要用多应用(app\controller\article\ArticleController 这种路径),需要安装多应用模式:

composer require topthink/think-multi-app

然后把控制器放到 app/controller/article/ArticleController.php,路由改成:

Route::get('article', 'article/ArticleController/index');

坑 4:数据库连接配置写错了

❌ 错误示例:

改了半天代码,查询一直报错 SQLSTATE[HY000] [1045]

✅ 检查配置:

打开 config/database.php,检查:

return [
'default' => 'mysql',
'connections' => [
    'mysql' => [
        'type'     => 'mysql',
        'hostname' => '127.0.0.1',
        'database' => 'cms_demo',    // ← 确保数据库名对
        'username' => 'root',        // ← 确保用户名对
        'password' => 'your_password', // ← 确保密码对
        'hostport' => '3306',
    ]
]
];

坑 5:N+1 查询问题

❌ 错误示例:

// 循环查用户信息,100 条记录就查 100 次数据库
foreach ($articles as $article) {
$article->author_name = User::find($article->user_id)->name;
}

✅ 正确写法:预加载:

// 用 with 预加载,一次查询搞定
$list = Article::with('user')->select();

// 模型里定义关联
class Article extends Model
{
public function user()
{
    return $this->belongsTo(User::class, 'user_id');
}
}

性能小贴士:批量写入用 insertAll

插入 1000 条数据,别一条一条 create()

// ❌ 慢:1000 次 INSERT
for ($i = 0; $i < 1000; $i++) {
Article::create(['title' => "文章 $i"]);
}

// ✅ 快:1 次 INSERT(批量)
$data = [];
for ($i = 0; $i < 1000; $i++) {
$data[] = ['title' => "文章 $i", 'create_time' => time()];
}
Article::insertAll($data);

调试技巧:打印 SQL 语句

想看看模型实际执行了什么 SQL?

// 方式 1:查看最后一条 SQL
echo Article::getLastSql();

// 方式 2:开启 SQL 日志(config/database.php)
'break_sql' => true,  // 遇错断点

// 方式 3:闭包调试
Article::where('id', '>', 0)->select(function($query) {
dump($query->fetchSql()->find());
});

✏️ 练习题 + 作业题(共 7 分钟)

练习题(5 道,10 分钟)

练习 1(2 分钟):改个标题
- 输入:把项目 1 接口的返回示例改成你的名字
- 预期输出:"msg": "你的名字的查询成功"
- 提示:只改 return json() 里的一项


练习 2(2 分钟):加个状态筛选
- 输入:在项目 1 列表接口加一个 status 参数,只返回指定状态的的文章
- 预期输出:传 ?status=1 只返回 status=1 的记录
- 提示:用 where() 方法过滤


练习 3(3 分钟):换数据源
- 输入:把项目 2 的数据表换成「商品表」(自己建一个 product 表,字段自定)
- 预期输出:后台页面能增删商品
- 提示:复制 Article 模型改名为 Product,修改对应的表名和字段


练习 4(5 分钟):串起来
- 输入:用项目 2 的后台新增文章,用项目 3 的导出功能导出 CSV
- 预期输出:导出的 CSV 文件里包含你新增的文章
- 提示:确保路由都配好了,先新增再导出


练习 5(3 分钟):报错分析
- 输入:运行项目时报错 Call to undefined method Article::selectAll()
- 预期输出:分析原因并修复
- 提示:ThinkPHP 模型没有 selectAll 方法,应该用哪个?


作业题(30 分钟 - 2 小时)

作业:做一个「小明的奶茶店库存管理小工具」

需求描述:
帮小明的奶茶店做一个简单的库存管理后台,管三种原料:珍珠、奶盖、茶叶。每种原料有「名称」「库存量」「预警值」(低于这个数就提醒补货)。

功能点:
1. 列表页:显示所有原料及库存状态(正常/需补货)
2. 新增/编辑:修改原料信息和库存量
3. 预警功能:库存低于预警值时,显示红色「⚠️ 需补货」标记
4. 导出:把库存列表导出为 CSV

加分项:
1. 支持按名称搜索
2. 支持批量导入 CSV

验收标准:
- 能跑起来(无报错)
- 列表页正常显示所有原料
- 新增后列表实时更新
- 预警标记逻辑正确(低于预警值显示警告)


📚 总结 + 资源

本文学了 3 件事:

  1. ThinkPHP 6 的开发模式——模型负责数据库、控制器负责逻辑、视图负责展示,约定大于配置
  2. 增删改查不用手写 SQL——模型自带方法,一行代码搞定「插删改查」
  3. 前后端分离做后台——后端提供 API,前端用 HTML/JS 渲染页面,分工清晰

延伸学习资源:


互动钩子:

「你在实际项目里用过 ThinkPHP 吗?遇到过的最大坑是什么?评论区聊聊,老粉优先回复!」


下章预告:

「学会了 ThinkPHP 6,你已经能快速搭后台了。但如果你想做一个面向移动端的 API 接口(给小程序、App 用),ThinkPHP 就有点大材小用了。下一章我们换 Laravel,用它来搭一套完整的 RESTful API——前后端彻底分离,数据只返回 JSON,接口规范又漂亮。敬请期待!」

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。