简介:这是一套基于ThinkPHP框架开发的轻量级CMS建站系统PHP源码,面向Web开发初学者与中小型项目开发者,解决快速搭建多语言、可扩展网站内容管理平台的需求。资源包共1303个文件,涵盖498个核心PHP脚本(实现路由、模型、模板渲染等逻辑)、133个JavaScript文件(增强交互体验)、108个HTML与47个CSS文件(构建响应式前端结构与样式)、215个PNG及196个GIF图像(支撑界面视觉呈现),以及配置类、文档类和Shell脚本等辅助文件,整体压缩包仅15.32MB,结构清晰、模块分明。已有494人学习下载,适合作为ThinkPHP实战入门范例,可直接部署调试,深入理解MVC架构落地、多语言切换机制、静态资源组织方式及CMS典型功能模块划分。
1. 这不是“套模板”,而是一套可落地的CMS建站逻辑
ThinkPHP框架、PHP语言、CMS系统——这三个词凑在一起,很多人第一反应是“又一个网上下载就能跑的源码包”,点开压缩包解压、改数据库配置、导入SQL、访问install.php,然后就卡在后台登录页404或者首页空白。我带过十几支小团队做企业官网、行业门户、内部知识库项目,几乎每支队伍都踩过这个坑:拿到一套标着“ThinkPHP6.0+CMS”的PHP源码,以为能省三个月开发时间,结果花两周在环境兼容性、路由失效、扩展缺失、权限校验绕过上反复折腾。问题从来不在“能不能跑”,而在于“为什么这样设计”“哪些模块必须重写”“哪些配置项实际影响线上稳定性”。这套源码本质不是成品软件,而是一套可裁剪的建站骨架——它把内容建模、权限分层、模板渲染、API出口这四根主梁搭好了,但墙体材料(字段类型)、门窗尺寸(接口粒度)、水电走向(缓存策略)全得你自己定。比如它的“文章模型”默认只支持标题+正文+分类ID,但你要做影视站就得加“上映年份、豆瓣评分、导演数组、播放源列表”;你要做电商CMS就得把“栏目”改成“商品类目”,把“文章发布”升级成“SKU多规格管理”。这不是修修补补,而是理解它的路由分发机制(thinkphp6的Route::rule vs Route::group)、中间件加载顺序(AuthMiddleware必须在ValidateMiddleware之后)、以及模型关联查询的N+1陷阱(用with(['category','tags'])替代循环查)。我去年帮一家教育机构重构旧CMS,把原系统里散落在控制器里的Excel导出逻辑,抽成独立Service类+Command命令行任务,再配合Redis队列做异步处理,导出10万条学员数据从38秒降到2.3秒——这种优化根本不会出现在源码注释里,但它才是真实项目里决定交付质量的关键。
2. 框架选型与CMS架构的底层逻辑拆解
2.1 为什么是ThinkPHP而不是Laravel或CodeIgniter?
选框架不是比谁功能多,而是看谁的“默认约定”最贴合CMS场景。ThinkPHP6.0的目录结构天然适配内容管理系统:app/common/model/放基础模型(Article、Category、Tag),app/admin/controller/专管后台操作(ArticleController、CategoryController),app/index/view/对应前台模板(article/index.html、category/list.html)。这种物理隔离让新人能快速定位“我要改文章列表页,该动哪个文件”。反观Laravel,虽然Eloquent ORM更强大,但CMS常见的“一篇文章关联多个标签+多个分类+多个附件”这种多对多嵌套,在Laravel里要写$article->tags()->sync([1,3,5]),而在ThinkPHP里直接$article->tags = [1,3,5]; $article->save();——少两行代码,但对非资深PHP开发者意味着调试时间减少40%。更重要的是ThinkPHP的“行为扩展”机制:CMS必备的“内容审核通过后自动发邮件通知作者”,在Laravel里得写Observer监听model事件,而在ThinkPHP里只需在app/common/behavior/下新建SendEmailBehavior.php,注册到app/config/behavior.php,一行配置'article_write' => ['app\\common\\behavior\\SendEmailBehavior']就搞定。这种“配置即功能”的设计,让运营人员也能参与流程定制。至于CodeIgniter,它轻量是优势也是枷锁——CMS需要的RBAC权限控制、多级缓存、API Token验证,CI得自己从零造轮子,而ThinkPHP6内置think-auth扩展包,三行代码就能实现“管理员能看到所有文章,编辑只能看自己发布的,审核员只能看待审状态”。
2.2 CMS核心模块如何被ThinkPHP特性支撑?
CMS不是堆功能,而是解决三个本质矛盾:内容生产效率 vs 内容质量管控、前台展示灵活性 vs 后台维护复杂度、单机性能瓶颈 vs 高并发访问压力。ThinkPHP用具体机制化解这些矛盾:
内容生产效率与质量管控的平衡:ThinkPHP的验证器(Validate)不是简单表单校验。比如“文章封面图”字段,验证规则
['require'=>true, 'fileSize'=>1024*1024*2, 'fileExt'=>'jpg,png,gif'],但真正关键的是'fileMime'=>'image/jpeg,image/png,image/gif'——这行代码防止用户上传.php伪装的图片马。CMS后台常被攻击者利用上传漏洞,而ThinkPHP6的FileValidate类会读取文件二进制头(Magic Number)而非仅依赖后缀名,这才是防御本质。我见过太多项目只校验后缀,结果黑客传shell.jpg,再用/index.php?s=/Uploads/shell.jpg/.php绕过解析限制。前台展示灵活性与后台维护复杂度的解耦:ThinkPHP的模板引擎支持原生PHP语法,但CMS项目必须禁用
{php}...{/php}标签。正确做法是用{volist name="list" id="vo" empty="暂无数据"}遍历,用{:url('article/read', ['id'=>$vo.id])}生成URL。这样做的好处是:当后台URL规则从/article/123改成/news/2024/123时,只需改route/app.php里的Route::rule('news/:year/:id','index/article/read');,前台所有链接自动生效。如果模板里硬编码<a href="/article/{$vo.id}">,那全站得grep替换。单机性能与高并发的缓冲设计:ThinkPHP的缓存驱动不是简单开关。CMS首页的“热门文章”区块,用
Cache::tag('article')->set('hot_list', $data, 3600),当某篇文章被编辑时,执行Cache::tag('article')->clear()——这比Cache::delete('hot_list')聪明得多,因为可能还有'new_list'、'recommend_list'等其他缓存,它们同属article标签,一次清除全搞定。而Redis作为缓存后端时,ThinkPHP6默认用redis://127.0.0.1:6379?database=1,但生产环境必须改成redis://127.0.0.1:6379?database=1&timeout=3&read_timeout=3&retry_interval=1000,否则网络抖动时请求会卡死15秒。
提示:ThinkPHP6的
config/app.php中'default_return_type' => 'html'是安全底线。曾有项目为做APP接口,改成'json',结果所有后台AJAX请求返回JSON,但前端JS没适配,导致“添加栏目”按钮点击后页面跳转到纯JSON文本页——这是框架配置与业务逻辑错位的典型事故。
3. 源码实操:从零部署到安全加固的完整链路
3.1 环境准备——避开PHP版本与扩展的深坑
ThinkPHP6.0官方要求PHP>=7.2.5,但实际项目必须用PHP7.4或8.0。原因很现实:PHP7.3在2021年已停止安全更新,而CMS常需对接微信支付、支付宝SDK,这些第三方库在PHP7.3下存在json_last_error_msg()函数不存在的兼容问题。我测试过PHP8.1,发现其严格类型检查会让ThinkPHP某些动态调用报Fatal Error,比如$model->where('status', 1)->select()在PHP8.1下需明确$model->where('status', 1)->select()->toArray(),否则返回对象而非数组——这对老项目迁移是灾难。所以生产环境锁定PHP7.4.33(Ubuntu 20.04默认源)最稳妥。
扩展安装不能只装pdo_mysql和openssl。CMS必备的三个冷门扩展:
fileinfo:用于文件上传MIME类型校验,禁用此扩展等于关闭文件安全闸门;mbstring:中文字符截取、URL编码转换必需,缺它会导致substr('你好世界',0,2)返回乱码;gd或imagick:生成缩略图、水印图的核心,ThinkPHP的think\Image类默认走GD,但GD处理大图内存溢出,此时需切换到Imagick。
验证命令不是php -v,而是:
php -m | grep -E "(fileinfo|mbstring|gd|openssl)" php -r "echo mb_internal_encoding();"如果mb_internal_encoding()输出不是UTF-8,必须在php.ini中加mbstring.internal_encoding = UTF-8,否则CMS后台编辑中文标题时会存成????。
注意:Nginx配置里
fastcgi_param PHP_VALUE "open_basedir=/www/wwwroot/cms/:/tmp/:/proc/";这行至关重要。它把PHP脚本活动范围锁死在网站根目录、临时目录、进程目录,即使黑客上传了webshell,也读不了/etc/passwd或/www/wwwroot/other_site/config.php。很多CMS源码没写这行,部署时必须手动补上。
3.2 数据库初始化——字段设计决定后期扩展成本
CMS源码自带的think_cms.sql通常只建了tp_article、tp_category、tp_user三张表,但这远远不够。真实项目必须提前规划五类扩展字段:
| 表名 | 字段名 | 类型 | 说明 | 实操技巧 |
|---|---|---|---|---|
tp_article | seo_title | varchar(200) | SEO优化标题,长度必须大于title | 在后台编辑页用JS同步:$('#title').on('input',function(){ $('#seo_title').val($(this).val()); }); |
tp_article | sort_order | int(11) | 手动排序权重,数值越大越靠前 | ThinkPHP查询时用order('sort_order desc, create_time desc'),避免只按时间排序 |
tp_category | template | varchar(100) | 栏目专属模板名,如list_news.html | 前台控制器里$this->view->engine->layout(false); $this->fetch($category['template']); |
tp_user | last_login_ip | varchar(45) | 记录最后登录IP,用于安全审计 | ThinkPHP的Request::ip()获取IPv6兼容地址,存入时用inet_pton()转二进制存,查时用inet_ntop()转回 |
tp_attachment | is_remote | tinyint(1) | 1=远程图床,0=本地存储 | 配合OSS/七牛云SDK,上传成功后存is_remote=1,前台<img src="{$vo.url}">自动适配 |
导入SQL后,立刻执行ALTER TABLE tp_article ROW_FORMAT=DYNAMIC;。InnoDB表默认COMPACT格式,当TEXT字段超长时会触发“页分裂”,导致查询变慢。DYNAMIC格式把大字段存到溢出页,主键索引页更紧凑。我优化过一个10万文章的CMS,加这行后首页加载从1.8秒降到0.6秒。
3.3 路由与权限——让后台不变成裸奔的数据库入口
ThinkPHP6的路由不是装饰品,而是安全防线。CMS源码常把后台入口设为/admin,但这等于告诉黑客“这里能登录”。正确做法是:
- 在
route/app.php中定义非常规路径:
// 后台入口伪装成静态资源 Route::rule('static/admin.js', 'admin/index/login'); // API接口统一前缀 Route::group('api/v1', function () { Route::rule('articles', 'api/Article/index'); Route::rule('articles/:id', 'api/Article/read'); });- 权限中间件
app/middleware/Auth.php必须包含三重校验:
- Session是否存在且未过期(
session_id() && session_status() === PHP_SESSION_ACTIVE); - 用户状态是否启用(
$user['status'] == 1,防离职员工账号被复用); - IP白名单校验(
in_array(Request::ip(), config('auth.ip_whitelist')),运维同事的办公IP才允许登录后台)。
- 最关键的是菜单权限动态生成。不要在数据库存死的
menu_list,而是在app/admin/controller/Index.php的index()方法里:
$menus = Db::name('auth_rule')->where('status',1)->order('sort','asc')->select(); // 过滤掉当前用户无权访问的菜单 $allowMenus = Auth::getInstance()->getAllowRule(); $this->assign('menus', array_filter($menus, function($m) use ($allowMenus){ return in_array($m['name'], $allowMenus); }));这样即使黑客拿到管理员账号,也无法看到“数据库备份”、“SQL执行”等高危菜单——权限控制在视图层就完成了。
实操心得:ThinkPHP的
think-auth扩展包默认用auth_group_access表关联用户和角色,但CMS常需“一人多角色”(如编辑兼审核员)。这时必须改app/common/model/AuthGroupAccess.php的$pk = 'uid';为$pk = ['uid','group_id'];,否则重复插入会报主键冲突。这个细节90%的教程都不会提,但上线后用户反馈“无法分配第二个角色”时,你得翻源码找两小时。
4. 安全加固与性能调优的实战细节
4.1 ThinkPHP漏洞的主动防御清单
搜索热词里高频出现“thinkphp漏洞”,其实90%是配置不当或版本滞后所致。针对CVE-2018-15512(远程代码执行)、CVE-2019-10715(SQL注入)等历史漏洞,光升级到6.0.9不够,必须做四件事:
关闭调试模式:
app/config/app.php中'debug' => false,且生产环境APP_DEBUG=false环境变量必须生效。调试模式开启时,ThinkPHP会暴露$_SERVER全局变量,黑客能直接看到DOCUMENT_ROOT路径。过滤危险函数调用:在
app/middleware/Security.php中拦截:
public function handle($request, \Closure $next) { $content = file_get_contents('php://input'); if (preg_match('/(eval|assert|system|exec|shell_exec|passthru|proc_open|popen|curl_exec|file_get_contents)/i', $content)) { abort(403, 'Forbidden'); } return $next($request); }注意:不能只过滤GET参数,POST原始体(如JSON)同样危险。
- 数据库配置强制参数化:ThinkPHP的
Db::table('article')->where('id',$id)->find()是安全的,但Db::query("SELECT * FROM tp_article WHERE id = {$id}")就是定时炸弹。必须在app/config/database.php中加:
'params' => [ PDO::ATTR_EMULATE_PREPARES => false, // 禁用模拟预处理 PDO::MYSQL_ATTR_DIRECT_QUERY => false, // 禁用直接查询 ],这样即使SQL里拼接变量,PDO也会报错而非执行。
- 静态资源强制HTTPS:在Nginx配置里加:
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; # 强制HTTPS引用 if ($scheme = http) { return 301 https://$server_name$request_uri; } }防止HTTP页面加载HTTPS资源被浏览器拦截,导致后台JS失效。
4.2 性能瓶颈的精准定位与突破
CMS性能问题80%出在“看不见的IO”。用ab -n 1000 -c 100 http://localhost/index.php压测时,平均响应时间2.3秒,但top看CPU才15%,内存充足——这说明是IO阻塞。用ThinkPHP内置的think\facade\Log记录慢查询:
// 在app/middleware/LogQuery.php中 Db::listen(function ($sql, $time, $explain) { if ($time > 100) { // 超100ms记日志 Log::record("Slow SQL: {$sql} | Time: {$time}ms", 'sql'); } });结果发现SELECT * FROM tp_article WHERE status=1 ORDER BY create_time DESC LIMIT 0,10耗时850ms。优化不是加索引,而是重构查询逻辑:
- 原SQL问题:
ORDER BY create_time DESC在百万级数据下必走全表扫描; - 解决方案:创建复合索引
ALTER TABLE tp_article ADD INDEX idx_status_time (status, create_time);,但更彻底的是用“游标分页”替代LIMIT:
// 首页第一次查 $lastTime = Db::name('article')->where('status',1)->order('create_time desc')->value('create_time'); // 下一页查:WHERE create_time < '{$lastTime}' AND status=1 ORDER BY create_time DESC游标分页使查询时间稳定在15ms内,且不受OFFSET增大影响。
另一个隐形杀手是模板渲染。CMS首页常调用{widget name="hot_articles"},而app/common/widget/HotArticles.php里写$list = Db::name('article')->where('status',1)->limit(5)->select();——每次访问首页都查5次数据库。正确做法是:
// 用Redis缓存结果 $cacheKey = 'hot_articles_'.date('Ymd'); $list = Cache::get($cacheKey); if (!$list) { $list = Db::name('article')->where('status',1)->order('view_count desc')->limit(5)->select(); Cache::set($cacheKey, $list, 3600); }但要注意:当文章阅读数更新时,必须Cache::delete('hot_articles_'.date('Ymd')),否则缓存永远不刷新。
4.3 部署上线前的终极 checklist
这份清单来自我经手的37个CMS项目上线前核查,漏一项就可能引发线上事故:
| 检查项 | 操作命令/位置 | 不通过后果 | 我的实测案例 |
|---|---|---|---|
| PHP错误报告关闭 | php -i | grep "display_errors"输出Off | 黑客通过报错获取服务器路径 | 某客户站因display_errors=On,报错泄露/www/wwwroot/cms/app/common/model/Article.php路径,被扫出备份文件/www/wwwroot/cms/backup_2023.sql |
| 静态资源Gzip压缩 | curl -H "Accept-Encoding: gzip" -I http://site.com/static/js/app.js返回Content-Encoding: gzip | 页面加载慢3倍 | 某教育站未开Gzip,首页JS 1.2MB,3G网络下加载23秒,跳出率78% |
| 数据库连接池 | mysql -u root -p -e "SHOW VARIABLES LIKE 'max_connections';"≥200 | 高并发时连接拒绝 | 双十一活动页瞬时QPS 1200,max_connections=150导致502错误率32% |
| 日志切割配置 | logrotate /www/wwwroot/cms/runtime/log/*.log | 磁盘爆满宕机 | 某政务站日志未切割,3个月积累42GB,/var分区100%,网站全部500 |
| ThinkPHP日志等级 | app/config/log.php中'level' => 'error' | 调试日志泄露敏感信息 | 后台登录失败日志含password=123456明文,被日志分析工具抓取 |
特别提醒:runtime目录权限必须是755,且属主为Web服务器用户(如www-data)。曾有项目用chmod 777 runtime,结果黑客上传shell.php到runtime/cache/,通过http://site.com/runtime/cache/shell.php直接执行——因为Nginx配置了location ~ \.php$ { ... },对所有PHP文件一视同仁。
5. 常见问题与排查技巧实录
5.1 “后台404”问题的三层诊断法
CMS部署后最常见问题是后台打不开,显示404。别急着重装,按三层顺序排查:
第一层:Web服务器路由是否生效
- Nginx检查
location / { try_files $uri $uri/ /index.php?$query_string; }是否存在; - Apache检查
.htaccess是否启用RewriteEngine On; - 直接访问
http://site.com/index.php?s=/admin/index/index,如果能打开,说明是伪静态规则问题。
第二层:ThinkPHP路由是否注册
- 查
route/app.php是否包含Route::domain('admin.site.com', function () { Route::group('admin', function () { Route::rule('index', 'admin/index/index'); }); });; - 在
app/admin/controller/Index.php的index()方法开头加dump('controller reached');exit;,如果没输出,说明路由没匹配到控制器。
第三层:权限与中间件拦截
- 临时注释
app/middleware/Auth.php中的所有校验代码,只留return $next($request);; - 如果此时能进后台,说明是Session失效或IP白名单问题;
- 检查
session.save_path是否指向可写的目录(/var/lib/php/sessions需chown www-data:www-data)。
我遇到过最诡异的案例:后台404,但index.php?s=/admin/index/index能打开。最终发现是Nginx的fastcgi_split_path_info正则写错了,把/admin/index/index误拆成/admin/index和index,导致PATH_INFO丢失。修复正则fastcgi_split_path_info ^(.+?.php)(/.*)$;后立即解决。
5.2 “文章列表空白”背后的模板引擎陷阱
前台文章页一片空白,Chrome开发者工具看Network标签页,HTML返回空内容。这不是PHP报错,而是模板引擎故障。ThinkPHP的fetch()方法有三个隐藏参数:
$this->fetch('article/index', [], ['layout' => false, 'vars' => ['title' => '文章列表']]);但CMS源码常写成$this->fetch('article/index'),漏掉[]参数。当模板里用了{include file="public/header"},而public/header.html不存在时,ThinkPHP默认静默失败,不报错也不输出——这就是空白页根源。
排查步骤:
- 在
app/index/controller/Article.php的index()方法末尾加dump($this->view->getTemplate());,确认模板路径正确; - 手动访问
http://site.com/template/article/index.html,看是否404(说明模板文件缺失); - 在
app/config/template.php中临时开启'auto_layout' => true,强制使用布局模板,看是否报Layout template not exists。
解决方案:所有fetch()调用必须显式传入空数组[]作为第二个参数,避免参数错位。这是ThinkPHP6的BC Break(向后不兼容变更),但文档极少强调。
5.3 “上传图片失败”的八种可能性及对应命令
CMS图片上传失败,错误提示常是“上传失败,请重试”,但背后原因各异:
| 错误现象 | 检查命令 | 解决方案 |
|---|---|---|
| 上传按钮无反应 | grep -r "upload" app/admin/view/查JS是否绑定事件 | 补$('#upload-btn').click(function(){...}); |
| 选择文件后进度条不动 | curl -I http://site.com/upload.php看是否返回500 | 检查upload_max_filesize是否小于文件大小 |
| 上传后提示“文件类型不允许” | php -r "print_r(getimagesize('/tmp/test.jpg'));";测试GD扩展 | 重装GD扩展或换Imagick |
| 上传成功但前台不显示 | ls -l runtime/upload/看文件权限 | chmod -R 755 runtime/upload |
| 上传后数据库没记录 | tail -f runtime/log/sql.log看INSERT语句 | 检查tp_attachment表是否有is_remote字段 |
| 多图上传只存一张 | var_dump($_FILES['files']['name']);在控制器里打印 | 前端<input type="file" name="files[]" multiple>必须带[] |
| 上传大图内存溢出 | php -r "echo ini_get('memory_limit');" | 改memory_limit=256M |
上传后URL是/uploads/2023/12/abc.jpg但404 | ls -l public/uploads/看目录是否存在 | 创建软链接ln -s /www/wwwroot/cms/runtime/upload/ public/uploads |
最隐蔽的问题是:Linux系统/tmp目录被清理工具定期清空,而ThinkPHP上传临时文件默认存/tmp。解决方案是在app/config/filesystem.php中指定:
'disks' => [ 'upload' => [ 'type' => 'local', 'root' => runtime_path('upload'), ], ],这样所有上传文件都存到runtime/upload/,受CMS自身生命周期管理。
5.4 “后台登录后闪退”的Session劫持实战分析
用户登录后台后,几秒内自动退出,重新输入账号密码又正常。这不是浏览器问题,而是Session被覆盖。ThinkPHP默认用PHP原生Session,但CMS常集成微信登录、扫码登录等第三方认证,这些SDK会调用session_start(),导致Session ID重置。
诊断方法:
- 在
app/admin/controller/Login.php的login()方法里加:
dump(session_id()); dump($_SESSION);- 登录后立即在
app/admin/controller/Index.php的index()里加同样代码; - 对比两次输出的Session ID是否一致。
如果ID不同,说明中间件或第三方SDK重启了Session。解决方案:
- 在
app/middleware/CheckLogin.php开头加if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); }; - 或更彻底:改用Redis存储Session,在
app/config/session.php中:
'type' => 'redis', 'store' => [ 'host' => '127.0.0.1', 'port' => 6379, 'password' => '', 'select' => 2, 'timeout' => 30, ],Redis Session不会被第三方SDK干扰,且支持分布式部署。
踩过的坑:某项目用阿里云Redis,
'host' => 'r-bp1xxx.redis.rds.aliyuncs.com',但没配SSL,ThinkPHP6的Redis驱动默认走TCP,阿里云要求SSL连接。结果Session写入失败,用户登录后Session为空——查了三天才发现Redis连接日志里有Connection refused,最终加'scheme' => 'tls'解决。
6. 从源码到产品的关键跃迁:CMS的二次开发心法
拿到ThinkPHP CMS源码,不是复制粘贴就能交付。真正的价值在于理解它的“可扩展接口”,把源码变成你的产品底座。我总结出三条心法:
心法一:模型层是唯一可信源CMS里“文章”概念散落在控制器、模板、JS、SQL里,但只有app/common/model/Article.php是真理。所有业务逻辑必须在这里沉淀:
- 文章阅读数增加,写
$this->inc('view_count');而非Db::name('article')->where('id',$id)->setInc('view_count');; - 文章删除时,连带删附件,写
$this->deleteAttachment();方法,里面调unlink()和Db::name('attachment')->where('aid',$this->id)->delete();; - 文章SEO字段自动生成,写
protected function setSeoTitleAttr($value) { return $value ?: $this->title . '-' . config('site.name'); }。
这样做的好处是:当客户说“阅读数要按IP去重”,你只需改inc('view_count')方法,所有调用处自动生效。如果逻辑散在各处,改10个地方漏1个,数据就错乱。
心法二:模板继承优于复制粘贴CMS前台常需不同风格:PC端用Bootstrap,移动端用Vue,小程序用WXML。ThinkPHP的模板继承机制能完美解决:
- 在
app/index/view/base.html定义骨架:
<!DOCTYPE html> <html> <head>{block name="head"}{/block}</head> <body>{block name="body"}{/block}</body> </html>- PC模板
app/index/view/article/index.html:
{extend name="base"} {block name="head"}<link rel="stylesheet" href="/static/css/bootstrap.css">{/block} {block name="body"}<div class="container">...</div>{/block}- 小程序模板
app/index/view/wx/article/index.html:
{extend name="base"} {block name="body"}<view class="article">...</view>{/block}这样改一个base.html,所有页面样式同步更新。我做过一个政府项目,要求同一套CMS同时输出网页版、App WebView版、微信公众号版,用继承模板三天就搞定,若复制粘贴得改200+文件。
心法三:API化是CMS的终局形态CMS不该只是后台管理工具,而应是内容中枢。ThinkPHP6的api模块要这样设计:
/api/v1/articles返回标准JSON,字段{id,title,cover,content_html,author_name};/api/v1/articles/123加?format=markdown参数,返回Markdown源码供App渲染;/api/v1/articlesPOST提交时,自动校验token并记录操作日志到tp_api_log表。
这样,CMS就从“网站后台”升级为“内容工厂”,未来接Flutter App、React管理后台、甚至AI摘要生成服务,都不用改核心逻辑。去年我做的医疗CMS,把文章API接入医院微信服务号,患者扫码看科普文章,后台编辑一篇,三端(网页、App、公众号)同时更新——这才是CMS该有的样子。
最后分享一个小技巧:ThinkPHP6的think\facade\Db类可以动态切换数据库连接。CMS多租户场景下,不同客户站点用不同数据库,只需在app/common/model/BaseModel.php里重写getConnection():
protected function getConnection() { $tenantId = request()->header('X-Tenant-ID', 'default'); $config = config('database.connections.tenant_'.$tenantId); return Connection::connect($config); }这样一套CMS源码,就能支撑100个客户独立站点,每个客户数据物理隔离。这才是源码真正的生产力。
本文还有配套的精品资源,点击获取