简介:Annie导航2.0是一套基于ThinkPHP8.0、PHP8.1与MySQL5.7的开源导航网站模板及管理后台,主要面向需要快速搭建实用导航站、或研究PHP后台开发的站长与开发者。它采用Funadmin框架,内置后台一键统计访问人数、网站设置、分类设置、工具管理、友情链接、广告管理、联系方式与主题管理等模块,新增5套热门模版并修复了旧版样式问题,部署后可自由切换多套前台风格。压缩包共2000个文件、约54.43MB,包含704个JS交互脚本、344个PHP核心源码、183个CSS样式、131个HTML模版,以及SQL数据库文件、PNG/JPG/SVG图片素材、MD说明文档、环境配置示例和部署脚本,目录结构清晰,便于二次开发与上线。目前已有175人学习下载。资源附带完整后台功能代码、多套模版配置、数据库初始化脚本及配置示例,能帮助用户快速掌握Funadmin与ThinkPHP8的组合实践,低成本搭建可长期维护、可定制外观的导航平台,对个人站长和企业内部网址导航场景都较为适用。
1. 从导航源码包到ThinkPHP8后台:Annie导航2.0解决了什么问题
多数导航类源码只解决「页面长什么样」,不解决「网站内容谁来维护」。Annie导航2.0把这件事改成了后台配置驱动的流程:前台是模版目录,后台是管理设置,数据访问统一走ThinkPHP8.0的ORM,技术栈固定为PHP8.1和MySQL5.7。2.0版本新增了5个模版,同时修复了旧模版的一批样式兼容问题,后台基于Funadmin框架一并提供了访问人数统计、分类设置、广告管理、友情链接和联系方式管理等模块。后续按技术栈选型、后台模块实现、模版切换机制、部署排错逐层展开,适合正在搭导航站、或想把ThinkPHP8后台开发流程完整走一遍的人。
2. ThinkPHP8.0与PHP8.1的选型逻辑与前后台目录约定
2.1 为什么是ThinkPHP8.0而不是6.x
ThinkPHP8.0对PHP版本的最低要求是8.0,整条请求链路去掉了旧版本遗留的__autoload兼容层,对opcache更友好。Annie导航2.0选择PHP8.1来跑这套代码,直接收益是能使用枚举、readonly属性、array_is_list这些原生能力,业务代码里手写校验的部分明显减少。MySQL5.7从实际负载看也是匹配的:导航站的数据以分类、链接、站点配置为主,单表量级在十万行以内,5.7的InnoDB和查询优化器完全吃得消,云厂商默认镜像多,迁移和备份的坑少。
为什么不用Laravel也是同一个逻辑:Laravel功能全面,但服务容器、队列组件、事件系统对纯导航站属于过量设计,Composer依赖体积大,部署时要多处理一层环境问题。ThinkPHP的数据库配置、验证器、模板引擎全部内置,发布一个开源项目时能做到「压缩包解压、配好数据库、访问install页面」三步跑起来。对于以导航站模板为定位的源码包来说,这种轻量特性比框架生态更重要。
2.2 前台与后台拆分的目录约定
从源码包的文件分布能直接看出Annie导航2.0的应用结构。后台基于Funadmin框架,UI层使用了LayUI,前台主题目录里则能看到uikit.min.css这类独立UI框架的样式文件,说明前台并不依赖后台UI组件。一个典型的ThinkPHP8多应用目录结构如下:
project/ ├── app/ │ ├── common/ # 公共函数、常量、枚举 │ ├── admin/ # 后台管理应用(Funadmin核心) │ │ ├── controller/ # 分类管理、广告位、主题配置等 │ │ ├── model/ # 数据模型与关联关系 │ │ ├── view/ # 后台视图,使用LayUI布局 │ │ └── route/ # 后台路由定义 │ └── index/ # 前台展示应用 │ └── view/ # 前台模版目录,每个主题一个子目录 │ ├── default/ │ ├── new_theme_1/ │ ├── new_theme_2/ │ └── ... ├── config/ │ └── theme.php # 主题配置 ├── public/ │ ├── static/ │ │ └── themes/ # 各主题的CSS、JS、图片资源 │ └── index.php # 唯一Web入口 ├── runtime/ # 日志、缓存、编译模板 └── composer.jsonapp/index/view/下每个子目录对应一套模版,目录名就是主题标识。public/static/themes/下存放配套的CSS和静态资源,这样后台切主题时,前台视图层和资源引用能同时切换。runtime目录用来存放模板编译缓存和日志,部署时只需保证该目录可写,不需要额外配置。
2.3 PHP8.1在业务代码里的实际写法
PHP8.1的枚举类型非常适合管理后台的状态字段。导航分类的显示/隐藏、链接的启用/停用,过去通常用status字段配合常量类,现在可以直接落到枚举上:
<?php declare(strict_types=1); namespace app\common\enum; enum NavStatus: int { case Disabled = 0; // 隐藏 case Enabled = 1; // 显示 public function label(): string { return match ($this) { self::Disabled => '隐藏', self::Enabled => '显示', }; } public static function fromValue(int $value): self { return match ($value) { 0 => self::Disabled, 1 => self::Enabled, default => throw new \InvalidArgumentException('非法状态值'), }; } }枚举的底层值是int,对应MySQL的tinyint字段。控制器里写NavStatus::Enabled->value作为查询条件,模版里调用$status->label()输出文字,不再有散落在各处的魔法数字。fromValue()方法负责接口入参的安全转化,外部传了2或99这类值时会直接抛异常,而不是带着脏数据查库。
后台路由在ThinkPHP8里也可以用分组方式集中定义,把主题切换和链接状态更新这类操作单独拎出来:
<?php // route/admin.php use think\facade\Route; Route::group('admin', function () { Route::get('dashboard', 'admin.Dashboard/index'); Route::resource('category', 'admin.Category'); // 分类的增删改查 Route::post('theme/switch', 'admin.Theme/switch'); // 切换主题 Route::put('link/:id/status', 'admin.Link/status'); // 修改友链状态 })->middleware(\app\middleware\AdminAuth::class);Route::resource会自动映射index/create/save/read/edit/delete七组方法,适合后台的标准资源管理。状态更新单独用PUT方法定义,是因为这类操作只改一个字段,没有必要走完整的update资源路由,接口语义也更清晰。整个路由组挂上AdminAuth中间件,未登录请求统一拦截。
3. Funadmin后台:访问统计、广告位与主题管理模块实现
3.1 后台功能模块与数据表对应关系
Annie导航2.0后台拆成了多个独立配置模块,每个模块对应一张或两张核心表。整理后的对应关系如下:
| 功能模块 | 后台入口 | 核心数据表 | 职责说明 |
|---|---|---|---|
| 网站设置 | 系统设置-网站设置 | setting | 键值对存储站点标题、关键词、统计代码 |
| 分类设置 | 内容管理-分类管理 | nav_category | 导航分类的排序与启停状态,支持父级分类 |
| 工具管理 | 内容管理-工具管理 | nav_tool | 导航站常用小工具的开关与排序 |
| 友情链接 | 内容管理-友情链接 | friend_link | 页脚友链的名称、URL、logo地址 |
| 广告管理 | 运营管理-广告管理 | ad_slot | 首页横幅位、分类页顶部的图片与跳转地址 |
| 联系方式 | 内容管理-联系方式 | contact_info | 页脚展示的邮箱、社交媒体链接 |
| 主题管理 | 界面设置-主题管理 | setting+ 视图目录 | 当前主题标识与各主题独立配置项 |
setting表是后台的枢纽,主题标识、站点标题、统计代码这些"单值配置"全都放这里。其它业务表按模块拆分,是为了后台列表页可以直接查表渲染,避免在JSON字段里做复杂查询。
3.2 访问人数统计的前后端接通
后台一键式统计访问人数的实现并不复杂,关键是选对埋点位置。最常见的做法是写一个全局中间件,所有前台请求经过时记录一条访问数据,后台请求直接跳过:
<?php declare(strict_types=1); namespace app\middleware; use think\Request; use app\common\service\VisitService; class VisitRecorder { public function handle(Request $request, \Closure $next) { // 后台请求不统计,避免管理员操作污染前台数据 if (str_starts_with($request->pathinfo(), 'admin')) { return $next($request); } VisitService::record($request->ip(), $request->pathinfo()); return $next($request); } }中间件里只做两件事:判断是否为后台路径,然后调用统计服务。str_starts_with是PHP8的原生函数,比strpos($path, 'admin') !== false可读性好很多。统计服务把每日的PV和UV写入一张按日期分行的表:
<?php declare(strict_types=1); namespace app\common\service; use think\facade\Db; class VisitService { public static function record(string $ip, string $path): void { $today = date('Y-m-d'); $visitKey = md5($ip . '_' . $today); // 用IP+日期判断是否为当日新访客,生产环境可换成cookie方案 $isNewUv = !cache($visitKey); if ($isNewUv) { cache($visitKey, 1, 86400); } Db::table('daily_visit')->upsert( [ 'visit_date' => $today, 'pv' => Db::raw('pv + 1'), 'uv' => Db::raw('uv + ' . ($isNewUv ? 1 : 0)), 'last_path' => $path, 'update_time' => date('Y-m-d H:i:s'), ], ['visit_date'], ['pv', 'uv', 'last_path', 'update_time'] ); } }upsert()是ThinkPHP8查询构造器提供的方法,第一次访问时插入新行,之后按visit_date作为唯一键做字段累加。用Db::raw让数据库端完成pv + 1,避免先查后写造成的并发丢失。新访客判断用IP加日期做缓存key是轻量方案,能挡住连点刷页面的情况,但换IP就会多算UV,生产环境建议改用cookie标记或用户表的最后登录字段。
仪表盘的数据接口把今日数据和近7天趋势一次性返给前端,方便LayUI图表直接渲染:
public function stats() { $today = date('Y-m-d'); $row = Db::table('daily_visit') ->where('visit_date', $today) ->find(); $week = Db::table('daily_visit') ->whereBetween('visit_date', [ date('Y-m-d', strtotime('-6 days')), $today ]) ->order('visit_date', 'asc') ->column('visit_date, pv, uv', 'visit_date'); return json([ 'today_pv' => $row['pv'] ?? 0, 'today_uv' => $row['uv'] ?? 0, 'week' => array_values($week), ]); }column('visit_date, pv, uv', 'visit_date')把查询结果键名改成日期字符串,前端拿到后可以直接按顺序折线图展示。?? 0用于处理当天还没有任何访问的情况,避免报错。
3.3 广告位与分类设置的表结构设计
广告管理模块的核心表设计要兼顾前台调用效率和后台维护体验:
CREATE TABLE `ad_slot` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `slot_name` varchar(50) NOT NULL COMMENT '广告位名称,如首页顶部', `slot_code` varchar(30) NOT NULL COMMENT '前台调用的英文标识', `image_url` varchar(255) DEFAULT NULL COMMENT '图片地址', `link_url` varchar(255) DEFAULT NULL COMMENT '跳转地址', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `expire_time` datetime DEFAULT NULL COMMENT '到期时间,空表示长期投放', PRIMARY KEY (`id`), UNIQUE KEY `uk_slot_code` (`slot_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='广告位配置表';status用tinyint,expire_time允许为空,空值表示长期投放。slot_code建立唯一键,前台模版里直接按code取值,不需要和别的表做关联查询。分类表与此类似,额外加parent_id和sort字段,前台渲染时按sort升序输出,后台拖拽排序直接更新sort的值即可。
3.4 后台权限与多管理员
Funadmin框架自带了基于RBAC的权限体系,管理员表、角色表、菜单权限表三件套是现成的。Annie导航2.0把主题管理、分类设置、广告管理分别注册成独立权限节点后,站点编辑只分配"分类设置"就能登录后台,运营可以单独管理广告和友链,开发者保留全部权限。这种做法在开源项目里比较常见,部署后第一件事应该是创建管理员账号并清掉默认密码,避免源码包里的初始密码被人直接扫到。
4. 多模版切换机制与新增模版流程
4.1 主题配置与中间件动态切换
多模版切换的难点不在后台存一个主题名,而在前台渲染时如何让视图层和静态资源同时指向当前主题。Annie导航2.0的做法是在中间件里动态修改视图路径。先看配置文件:
<?php // config/theme.php return [ 'default' => 'default', // 兜底主题 'themes' => ['default', 'dark', 'grid'], // 可用主题列表 'path' => app()->getRootPath() . 'app/index/view/', ];path指向前台模版根目录,每个主题在这里建一个同名子目录。中间件读取后台保存的主题标识后,把视图路径替换成对应目录:
<?php declare(strict_types=1); namespace app\middleware; use think\Request; use think\facade\Db; use think\facade\Config; use think\facade\View; class ThemeMiddleware { public function handle(Request $request, \Closure $next) { // 先读缓存,没有再从数据库查,避免每个请求都查setting表 $theme = cache('site_theme'); if (!$theme) { $theme = Db::table('setting') ->where('name', 'theme') ->value('value'); $theme = $theme ?: Config::get('theme.default'); cache('site_theme', $theme, 3600); } $themeDir = Config::get('theme.path') . $theme . DIRECTORY_SEPARATOR; // 动态切换前台视图目录 View::config(['view_path' => $themeDir]); // 注入主题资源地址,模版里通过 {$themeUrl} 拼接CSS路径 View::assign('themeUrl', '/static/themes/' . $theme); return $next($request); } }这里的重点是cache('site_theme', $theme, 3600)。主题标识缓存一小时,后台切换主题后如果不清理缓存,用户会看到切换"不生效"。实际项目中我一般会在ThemeController的switch方法里同时执行cache('site_theme', null),保证切主题的即时性。View::config的view_path一旦被修改,后续所有view()调用都会解析到新主题目录下的模板文件,前台控制器完全不用感知主题的存在。
4.2 主题模版内的资源引用方式
主题目录里的模板文件,资源引用不能写死绝对路径。比如app/index/view/dark/index.html中:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="{$themeUrl}/uikit.min.css"> <link rel="stylesheet" href="{$themeUrl}/index.css"> </head> <body> {include file="header" /} ... </body> </html>{$themeUrl}是中间件里注入到视图层的变量,指向public/static/themes/dark。这样主题目录里只需要放header.html、footer.html、index.html这些模板文件,CSS和JS统一放到静态资源目录。换主题时,视图路径和资源路径同时切走,不会出现模版是新主题的、样式却是旧主题的情况。
新增模版需要特别注意填充的变量:模板里用到的分类列表、友情链接、广告位数据,都是从控制器分配过来的,新主题的模板文件必须保留这些变量名,否则页面会白屏。最省事的做法是以默认主题为基底复制一份,改样式和布局,不要从零写模板。
4.3 新增一套模版的最小操作流程
在Annie导航2.0上增加第6套模版,按下面几步操作:
# 1. 创建模版目录和静态资源目录 mkdir -p app/index/view/newtheme mkdir -p public/static/themes/newtheme # 2. 复制一套现有模版作为基底 cp -r app/index/view/default/* app/index/view/newtheme/ cp -r public/static/themes/default/* public/static/themes/newtheme/然后在config/theme.php的themes数组里加上newtheme,进入后台主题管理页面就会看到新主题,点击切换即生效。如果想把模版提交回上游仓库,需要按项目的开源文档贡献规范补一份主题说明文件,写清楚适用场景、依赖的UI框架和改动点,而不是直接往仓库里堆文件。发布前还要确认仓库声明的开源许可证类型,MIT和Apache-2.0对署名和专利授权的约束不同,二开项目尤其要注意保留原作者版权声明。
4.4 主题切换样式丢失的排查顺序
切主题后样式错乱,第一检查{$themeUrl}变量拼接是否正确;第二看浏览器控制台静态资源请求是否404;第三确认public/static/themes/下的目录名和模版目录名完全一致——Linux下大小写敏感,Dark和dark会被当成两个目录。最后再考虑缓存因素,本地开发可以临时把中间件里的缓存时间改成3600以外的小值,线上则依赖后台的清除缓存按钮。
5. 部署、伪静态与排错清单
5.1 Nginx伪静态与php-fpm配置
ThinkPHP8的项目入口在public/目录,Nginx的root必须指向这里,避免应用目录和runtime目录暴露在HTTP路径下:
server { listen 80; server_name yourdomain.com; root /var/www/annie/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~ \.(js|css|png|jpg|gif|svg|woff2)$ { expires 30d; access_log off; } }rewrite ^(.*)$ /index.php?s=$1 last;是ThinkPHP标准伪静态写法,把不存在的文件路径交给入口文件解析。静态资源单独加30天缓存,减少重复请求。php-fpm的socket路径要和本机实际安装的PHP版本对应,php8.1-fpm.sock写错会直接502。
5.2 环境兼容与数据迁移
源码包部署时,先在项目根目录执行依赖安装:
composer install --no-dev --optimize-autoloader--no-dev跳过开发环境依赖,--optimize-autoloader生成优化后的类映射,对PHP8.1和opcache配合更好。如果数据库配置已写入.env,可以用ThinkPHP的命令行工具做缓存预热:
php think optimize:route php think optimize:config这两条命令会把路由和配置合并成PHP数组文件写入runtime,减少每次请求的解析开销。MySQL5.7环境下数据迁移时注意字符集,表统一用utf8mb4排序规则utf8mb4_unicode_ci,避免表情字符写入报错。
5.3 常见问题排查表
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 页面500且无错误日志 | runtime目录不可写 | chmod -R 775 runtime,属主改为php-fpm运行用户 |
| 后台能进但页面空白 | opcache缓存旧代码 | 清opcache后执行php think clear |
| 访问统计全部为0 | VisitRecorder未注册或路径判断失误 | 检查app/middleware.php是否加载,后台路径是否带admin/前缀 |
| 切主题后样式丢失 | 静态资源路径404 | 核对目录名大小写,确认{$themeUrl}拼接正确 |
| 中文存入数据库变成问号 | 表字符集不是utf8mb4 | ALTER TABLE 表名 CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; |
| 安装时数据库连接超时 | MySQL5.7默认max_allowed_packet过小 | my.cnf中调大max_allowed_packet后重启数据库 |
5.4 切换主题后的缓存清理技巧
后台日常维护中,"缓存"是最容易模糊的环节。主题标识、分类列表、广告位映射都走了缓存,更新数据后必须让旧缓存失效。一个通用做法是后台维护操作结束后统一执行清理:
public function clearCache() { // 清理主题、分类、广告位的缓存 $keys = ['site_theme', 'category_list', 'ad_slot_map']; foreach ($keys as $key) { cache($key, null); } // 重新写入当前主题缓存,避免下次请求回源查库 $theme = $this->request->post('theme', 'default'); cache('site_theme', $theme, 3600); return json(['code' => 1, 'msg' => '缓存已刷新']); }这个方法的要点是先把旧缓存清干净,再写入当前的最新值,而不是只删不写。如果只删除不写入,下一个前台请求会短暂回源查库,高并发时这条查询会打到数据库上。实际项目中还可以把这个接口接到后台每个保存按钮的公共回调里,这样无论是改广告位还是切主题,完成操作后缓存自动重建。
本文还有配套的精品资源,点击获取