filamentboot/filamentboot-site

面向中小企业的前台官网管理插件,提供案例/方案/产品/资讯/页面/询盘六类内容管理,中文单语言,支持主题切换。

Maintainers

Package info

github.com/filamentboot/filamentboot-site

Documentation

pkg:composer/filamentboot/filamentboot-site

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.14.0 2026-08-13 10:38 UTC

This package is auto-updated.

Last update: 2026-08-13 11:34:35 UTC


README

面向中小企业的前台官网管理插件,提供案例/方案/产品/资讯/页面/询盘六类内容管理,中文单语言,支持主题切换。

🏠 官网https://www.xitongapp.com/ —— 本包驱动的官方产品站,software 主题的实例

简介

本包为 Filamentboot 后台增加完整的企业官网内容管理能力。注册 SitePlugin 后,后台将出现「官网管理」分组,包含 14 个 Resource——幻灯片(SiteBannerResource)、案例(SiteCaseResource)、方案(SiteSolutionResource)、套餐(SitePackageResource)、产品(SiteProductResource)、资讯文章(NewsArticleResource)、资讯分类(NewsCategoryResource)、城市页(SiteCityPageResource)、静态页面(SitePageResource)、导航菜单(SiteMenuResource)、菜单项(SiteMenuItemResource)、301 重定向(SiteRedirectResource)、询盘管理(ContactMessageResource)、站内搜索词(SiteSearchTermResource)——以及未读询盘小部件(UnreadContactMessagesWidget)。前台路由与视图由 SiteServiceProvider 自动注册。官网设置(公司信息、联系方式、SEO 默认值、主题、统计代码、在线客服等)通过后台「网站设置」页(SiteSettingsPage)管理。

部分 Resource 括号前的中文名会按 active_theme 变化(如「案例」在 decoration 主题下显示为「装修案例」),上面统一用 software 主题的通用叫法——两套官方模板都在维护,文档不再只照一套主题的说法写。

公开页零 Livewire、零 session。 前台内容页不挂 web 中间件组、不起会话、不含 Livewire 组件, 因此可以被 CDN 整页缓存(见下文「公开页缓存」)。前台交互一律用 Alpine + 无状态端点。

语言范围:当前版本只维护中文内容流。早期双语实现遗留的 *_en 列与站点设置项已全部删除(迁移 2026_08_08_130000_drop_legacy_english_and_gallery_columns2026_08_08_100001_drop_site_legacy_english_settings),字段一律只有 _zh 一套。

要求

  • PHP ^8.3、Laravel ^13、Filament ^5
  • ext-intlfilament/support 的硬依赖,缺失时批量删除/恢复/强删/导入的通知会抛 RuntimeException, 本包 SiteProductResource/SitePackageResource 的价格列(->money('CNY'))会 500。不要用 --ignore-platform-req=ext-intl 绕过
  • 依赖主包 filamentboot/filamentboot(版本约束跟随主包发布节奏,见 composer.json
  • livewire/livewire ^4.3(后台 Filament 页面依赖;前台不用)
  • ⚠️ danharrin/livewire-rate-limiting ^2.2 仍在 composer.json 里,但包内已无任何引用—— 询盘限流改成了框架自带的 RateLimiter(前台去 Livewire 化的连带结果)。属可清理项,未动
  • spatie/laravel-settings ^3.9(官网设置持久化)
  • filament/spatie-laravel-settings-plugin ^5.6(设置页表单集成)
  • filament/spatie-laravel-media-library-plugin ^5.6(媒体文件上传)
  • mews/purifier ^3.4(HTML XSS 过滤)

富文本过滤

前台正文一律经 Support\RichText::purify() 输出,白名单写在包内、与后台 RichEditor 的默认工具栏对齐(标题、引用、代码块、表格、上下标、删除线都放行,脚本与事件属性剥离)。 不读宿主 config/purifier.phpdefault 画像——那份白名单只有十来个标签, 拿它过滤编辑器产出的正文会把版式静默吃掉。

想自己定过滤策略:在 config/purifier.php 加一段画像,再把画像名填到 filamentboot-site.purifier_profile(或 .envSITE_PURIFIER_PROFILE),包内白名单即让位。

正文样式由各主题的 .prose 提供(resources/css/themes/{theme}.css)。 项目未装 @tailwindcss/typography,两套主题各写各的一份——自定义主题时别漏, 漏了正文会退回 Tailwind preflight 后的裸样式:标题与正文等大、列表没符号、段落无间距。

安装

先装主包(本包依赖 filamentboot/filamentboot,且要先完成主包的 filamentboot:install,本包的 SitePlugin 才有面板可挂):

composer require filamentboot/filamentboot
php artisan filamentboot:install
composer require filamentboot/filamentboot-site
php artisan filamentboot-site:install

一条命令依次做完:发布配置与前端资源 → 执行数据库迁移(25 张内容表)→ 写入权限点、三层角色、后台导航菜单三项结构性数据(均必需,缺导航菜单登记后台侧边栏不会出现「官网管理」分组,缺权限点/角色除超管外无人能进)→ 扫描并启用插件 → 清缓存。全程幂等,可重复执行。

要顺带看一遍演示效果(案例/方案/产品/资讯示例内容),加 --with-demo

php artisan filamentboot-site:install --with-demo

装完之后在 app/Providers/Filament/AdminPanelProvider.php 里注册插件(见下面「1. 注册插件」),前台首页即可访问。

装完(或升级完)跑一次体检:

php artisan filamentboot-site:doctor

七项检查——插件是否启用、迁移是否完整(23 张核心表)、结构性种子(权限点)是否跑过、 26 个关键路由能否解析、内容配置完整性(联系电话/ICP 备案号/默认 SEO 等发布前必填项)、 首页 HTTP 响应头(Cache-Control: public 且无 Set-Cookie)、媒体磁盘是否可写。任意一项 不通过退出码非零,报告里会指出具体是哪一条。

手动模式

php artisan vendor:publish --tag=filamentboot-site-config
php artisan vendor:publish --tag=filamentboot-site-assets
php artisan migrate

php artisan db:seed --class="Filamentboot\FilamentbootSite\Database\Seeders\SitePermissionSeeder"
php artisan db:seed --class="Filamentboot\FilamentbootSite\Database\Seeders\SiteRoleSeeder"
php artisan db:seed --class="Filamentboot\FilamentbootSite\Database\Seeders\SiteMenuSeeder"

三个结构性种子均必需:权限点与三层角色决定除超管外谁能进官网管理;后台侧边栏由 menus 表驱动而非 Filament 的静态导航属性生成(SitePlugin 注册的资源即使路由已就绪,未在 menus 表登记也不会出现在导航中)——漏跑 SiteMenuSeeder 的直接症状是「装完插件权限都对,但侧边栏找不到入口」。

不含迁移 tag:本包依赖 loadMigrationsFrom() 自动加载全部迁移(settings 迁移 + 25 张内容表迁移),不提供 vendor:publish 迁移出口——发布一份到 database/migrations/ 会被 migrate 同时扫描到两份。需要自定义迁移就手写新迁移文件,不要整包复制。

运行初始化种子数据(可选,等价于 --with-demo):

# 案例 / 方案 / 产品 / 静态页面 / 示例询盘
php artisan db:seed --class="Filamentboot\FilamentbootSite\Database\Seeders\SiteDemoSeeder"

# 资讯分类与文章
php artisan db:seed --class="Filamentboot\FilamentbootSite\Database\Seeders\SiteNewsSeeder"

两个种子可反复执行,按 slug 增量补种:已有记录一概不动(后台改过的文案不会被覆盖), 缺的补上,升级后新增的演示内容直接跑一遍就能拿到。软删除过的记录不复活。

封面图每次都会重试挂载,所以图片是后补的也没关系——放进 storage/app/public/site/{cases,solutions,news,products}/{slug}.jpg 再跑一遍即可。

演示数据后台开关

已经装完、不想再回终端敲命令:「网站设置」页顶部有"种入演示数据"/"清空演示数据"两个 按钮(仅超管可见)。"种入"等价于上面两个 db:seed 命令;"清空"按各 Seeder 精确删除 自己播种的记录(forceDelete(),不留孤儿媒体文件)并复位列表页导语,种入 → 清空 → 再 种入结果一致。清空前请确认列表页导语与导航菜单没有被运营改过——clear() 分不出 "demo 原文"与"改过的真内容",会一并清空且不可恢复,弹窗确认文案里也写了这条。

使用

1. 注册插件

app/Providers/Filament/AdminPanelProvider.php 中注册:

use Filamentboot\FilamentbootSite\SitePlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugins([
            SitePlugin::make(),
        ]);
}

注册后,Filament 面板自动挂载以下内容:

组件 说明
网站设置页 SiteSettingsPage 公司信息、联系方式、SEO 默认值、主题
幻灯片 SiteBannerResource 按投放位置管理首页与栏目页 Banner
装修案例 SiteCaseResource 含分类、标签、封面图、图集
智能方案 SiteSolutionResource 解决方案内容管理
全屋套餐 SitePackageResource 按户型 × 档位组织,含包含清单与参考价
智能产品 SiteProductResource 含分类、品牌、价格、图集、富文本详情
资讯文章 NewsArticleResource 含分类、标签、封面图,published_at 控制发布与定时
资讯分类 NewsCategoryResource 扁平分类,前台列表页筛选用
城市页 SiteCityPageResource 按行政区划批量建落地页,概况字段表由 config 声明
静态页面 SitePageResource 关于我们、联系我们、常见问题等
询盘管理 ContactMessageResource 只读 + 状态流转
站内搜索词 SiteSearchTermResource 只读报表,零结果的词即内容缺口
官网概览 UnreadContactMessagesWidget 未读询盘数 + 发布前健康检查

2. 前台路由挂载模式

前台路由由 config/filamentboot-site.phproute.mode 决定,默认 prefix,不抢占宿主根路由

模式 用途 示例 URL 环境变量
prefix(默认) 宿主已有前台业务 /site/about SITE_ROUTE_MODE=prefixSITE_ROUTE_PREFIX=site
root 项目本身就是官网 /about SITE_ROUTE_MODE=root
domain 官网使用独立域名 www.example.com/about SITE_ROUTE_MODE=domainSITE_ROUTE_DOMAIN=www.example.com

固定系统路径(sitemap.xmlrobots.txt 等)先于动态 /{slug} 注册,并通过 route.reserved_slugs 排除,不会被页面路由吞掉。

使用动态 robots.txt 时,需删除宿主的静态 public/robots.txt——Web 服务器会优先返回静态文件。

3. 前端资源构建

主题 CSS 与前台脚本都通过宿主的 Vite 构建。两件事都要做,缺一样前台就不完整

npm install -D alpinejs

vite.config.jsinput 里加上这两类入口(路径按你的安装形态选,见下):

input: [
    // …你自己的入口
    'vendor/filamentboot/filamentboot-site/resources/css/themes/decoration.css',
    'vendor/filamentboot/filamentboot-site/resources/css/themes/software.css',
    'vendor/filamentboot/filamentboot-site/resources/js/site.js',
],

然后 npm run build

为什么需要 alpinejs:前台的交互(导航抽屉、询盘面板、二级导航下拉、产品图集轮播)全靠 Alpine。 本包的公开页刻意不使用任何 Livewire 组件——Livewire 注入的脚本带 data-csrf,渲染时会调 csrf_token() 起 session,公开页就必然带 Set-Cookie,整页缓存无从谈起。所以 Alpine 由 resources/js/site.js 独立交付。漏了这一步,前台所有 x-data 都不工作。

入口路径有三种形态,包内会按 Vite manifest 实际命中的那个自动选(配置项 assets.vite_entriesassets.script_entries):

形态 路径
真实 Composer 安装 vendor/filamentboot/filamentboot-site/resources/{css,js}/…
vendor:publish --tag=filamentboot-site-assets 之后 resources/css/vendor/filamentboot-site/…resources/js/vendor/filamentboot-site/site.js
monorepo path 仓库(vendor 是符号链接) packages/filamentboot-site/resources/{css,js}/…

⚠️ 第三种形态有一个前提:符号链接指向的目录必须在项目树内。 Vite 算入口的 manifest 键时会把符号链接转换成真实磁盘路径、再相对项目根目录求相对路径——链接目标在根目录内, 算出来正是上表第三行;链接目标在根目录(比如另开一个目录存包源码,用 "repositories": [{"type": "path", "url": "../filamentboot-site", "options": {"symlink": true}}] 这种写法),算出来的相对路径会带一串 ../(实测形如 ../filamentboot-site/resources/js/site.js),三个候选一个都对不上,直接抛 Unable to locate file in Vite manifest——不是某个候选选错了,是候选列表天然覆盖不到这第四种 形态。规避办法:本地包开发时把包目录放在项目树内(本仓库自己就是这样),或者干脆 symlink: false(Composer 复制文件落进 vendor/,回到第一种形态)。

同一个机制还留了一个目前无害的死角:shared.css 里指向宿主覆盖视图的第四条 @source 是 6 层 ../ 相对路径,按「真实 Composer 安装」时的物理路径算得数——在 monorepo path 仓库开发形态下,shared.css 的真实磁盘路径是 packages/filamentboot-site/resources/css/ themes/,同样 6 层 ../ 会走出仓库树外,落到一个不存在的目录,Tailwind 的内容扫描静默 零命中,不报错。目前两套主题都还没人真的发布过视图覆盖,这条路径从未被真正用到过; 用 path 仓库开发时若要验证宿主覆盖视图能被前台样式正确扫描到,先知道这条路径在这种 安装形态下不生效。

公开页缓存

内容页响应头是 Cache-Control: public, max-age=600config('filamentboot-site.cache.public_max_age'), 设 0 关闭),不发 Set-Cookie,可直接上 CDN 整页缓存。

  • 询盘提交打的是 POST /contact-submissions,一条不挂 web 中间件组的无状态路由; 防刷是蜜罐 + 客户端上报耗时 + 每 IP 3 次 / 5 分钟限流 + 路由级 throttle
  • 首触渠道归因在客户端 localStorage 里,提交时随请求体发出。
  • ⚠️ 若你在内容路由上加了会起 session 的中间件,SiteCacheHeaders自动退回不缓存 (带会话 Cookie 的响应绝不能标成公共可缓存),但缓存收益也就没了。

4. 主题定制

内置两套主题:decoration(科技装修 · 浅色)与 software(软件产品 · 浅色),在「网站设置 → 外观」中切换,保存后自动清除视图缓存。

发布主题视图后即可覆盖包内模板:

php artisan vendor:publish --tag=filamentboot-site-views

视图解析优先级(先命中者生效):

resources/views/vendor/filamentboot-site/themes/{theme}/   ← 宿主发布覆盖
包内 resources/views/themes/{theme}/                        ← 主题模板
包内 resources/views/shared/                                ← 跨主题共享组件
包内 resources/views/                                       ← Livewire 视图兜底

主题清单 theme.php

每个主题目录下有一份 theme.php,声明这套主题支持什么:

return [
    'label'     => '科技装修(浅色)',
    'templates' => ['default', 'landing'],   // pages/templates/{key}.blade.php
    'blocks'    => ['hero', 'cta', 'faq'],   // blocks/{key}.blade.php
    'features'  => ['nested_menu' => true],  // 桌面导航有下拉版式
];

后台切换主题之前会按目标主题的清单算一遍:已发布页面用到的版式或区块若不在清单里, 就把受影响的页面逐条列出来,并要求勾选确认才放行。所以自定义主题时:

  • 加了 blocks/*.blade.phppages/templates/*.blade.php 就同步改清单,否则已支持的东西会被报成不支持。
  • features.nested_menu 决定 MenuResolver 返回层级还是摊平结构。未声明即按不支持处理,后台配出来的二级菜单项会被摊平到一层显示(不会丢)。
  • 清单缺失时按目录里实际存在的视图文件推断,但 features 一律按不支持——文件系统看不出一个导航栏有没有下拉版式。

5. 发布前检查

「网站设置」页与仪表盘会提示尚未配置的发布前必填项:联系电话、公司地址、ICP 备案号、隐私政策链接、默认 SEO 标题与描述、默认 Open Graph 图、公司 LOGO。未配置的项不会在前台渲染空栏目,但会在后台持续告警。

6. 三层角色

SiteRoleSeeder 建好三个开箱可用的角色,在「角色管理」里直接分配给管理员即可:

角色 五类内容读写 发布 / 定时发布 删除与版本回滚 站点设置 / 导航 / 重定向 询盘查看与导出
内容编辑 ❌ 只能提交审核
内容发布
站点管理

分层的实际意义在「内容编辑不能发布」这一条:写文案的人和对外发声负责的人往往不是同一个,publish_site_page 就是那道闸门。内容编辑在编辑页只看到「提交审核」,看不到「发布」与「定时发布」。

角色定义以代码为准:SiteRoleSeedersyncPermissions,重跑会把手工加到这三个角色上的权限刷掉。要给某人额外权限,新建角色或直接授予用户,不要改这三个。

超级管理员沿用主包的 Gate::before() 放行,不需要(也不应该)授予上述任何权限点。

7. 搜索引擎接入(可选)

「网站设置 → SEO 默认值」下有两组选填项:

  • 站长平台验证:填百度 / Google / Bing / 搜狗下发的验证串(只填串本身,不要粘整段 <meta>),自动输出到全站 <head>。字符集非法时不输出,避免把半截标签打进文档。搜狗那一项值得单独一提——它同时是腾讯元宝的检索源,做 AI 引用绕不开。
  • 百度主动推送:填 token 与站长平台登记的站点域名后,内容首次进入已发布态时自动把 URL 推给百度,比等抓取快一个数量级。只在发布状态变化时推,改正文不会烧配额(普通站 3000 条/天)。

存量内容回推:

php artisan filamentboot-site:push-baidu          # 试运行,只报条数
php artisan filamentboot-site:push-baidu --all    # 真正推送

未填 token 即视为关闭,不发请求也不占队列。

robots.txt 与 AI 抓取器

config('filamentboot-site.robots') 控制两段:通用段的 Disallow 路径,以及 AI 抓取器策略 (allow / disallow / omit,默认 allow,可用 SITE_ROBOTS_AI_POLICY 覆盖)。

⚠️ allow 与「不写 AI 段」的实际效果完全一致——通配符本来就覆盖那些 UA,差别只在把立场写明。 真要拒绝就改成 disallow。另外 robots.txt 的分组匹配是「只有最具体的那一组生效」,所以 allow 策略下会把通用段的 Disallow 在 AI 组里原样重复一遍;只写一行 Allow: / 等于把后台放出去。

/llms.txt

站根输出一份 Markdown 索引(llmstxt.org 约定),列站点栏目与各类内容的 带说明目录,每类取前 seo.llms_limit 条(默认 50)。它不替代 sitemap.xml——后者是给爬虫的 全量机器清单,前者是给模型看的简明目录。这是新兴约定,目前没有哪家明确声明会读它, 做它的理由是成本极低而位置唯一,先占住不亏。

爬虫抓取统计

站长平台的抓取数据滞后好几天,访问日志不滞后。AI 抓取器更是只能看日志——那几家都没有站长平台。

php artisan filamentboot-site:crawler-stats --log=/var/log/nginx/access.log
php artisan filamentboot-site:crawler-stats --since=2026-08-01 --paths

按 UA 归类输出抓取次数与状态码分布(2xx 占比偏低比抓取次数少更值得先查——那说明爬虫在撞死链)。 需要 nginx combined 格式;默认路径取 config('filamentboot-site.crawler_stats.access_log')。 只读不落库:日志本身就是数据源,再同步一份到数据库等于维护两套真相。

8. 站内搜索

前台 /search?q=关键词,两套主题各有一份结果页,导航栏与移动端抽屉都有入口。跨五类内容检索(页面、案例、方案、产品、资讯),每类最多 5 条。

搜索页固定输出 X-Robots-Tag: noindex, follow 且不出 canonical:关键词组合是无限的 URL 空间,被收录会产出成千上万低价值页面稀释整站权重。表单是 method="get",所以每个关键词各自是一个可缓存、可分享的地址。

两条已知边界(不是缺陷,是实现方式的代价):

  • 匹配用 LIKE '%词%'没有相关度排序,排序按各类型自己的自然顺序。内容量上万要换 MySQL FULLTEXT(中文还需 WITH PARSER ngram)。
  • 区块正文搜不到。 site_pages.blocks 是 JSON 列,中文在里面是 Unicode 转义序列,LIKE 匹配不到。纯用区块搭的页面只能靠标题被搜到——正文写在富文本里的页面不受影响。

搜索词统计

每次搜索按「词 + 累计次数 + 最近一次结果条数」写进 site_search_terms,后台「官网管理 → 站内搜索词」查看。 不记 IP、UA 或任何身份信息,要回答的是「大家在搜什么」而不是「谁搜了什么」。用 SITE_SEARCH_LOG_TERMS=false 关闭。

这张表最该看的是结果数为 0 的那一档——访客明确表达了需求而站上答不上来,每一条都是内容缺口, 所以后台默认就把它们排在最前面。热词榜谁都会看,这一档才真正决定下一批写什么。

写入走一条 on duplicate key update 原子语句:搜索是并发的,读一次再写一次会丢计数。 记录失败一律吞掉,绝不能把搜索页打成 500。

9. 标签聚合页

前台 /tags/{slug},两套主题各有一份视图。标签在案例 / 方案 / 套餐 / 产品 / 资讯五类详情页底部渲染成链接,点进去是该标签下的跨类型聚合。

标签是站上唯一一条跨内容类型的通路。 分类、户型、风格这些维度都只在自己那一类里有效,只有标签能把一个「节能环保」下的案例、方案、套餐、资讯串到一起。做这一页之前它们只是不可点的灰字,一条内链都不产生。

三条实现口径:

  • 只出已发布内容,草稿从这条路径泄露和从列表页泄露是一回事。
  • 该标签下没有任何已发布内容时返回 404,且不进站点地图。后台随手建个标签还没挂内容是常态,每建一个就多一个空页面被收录,那是在稀释整站权重。
  • 每类最多 24 条,超出时给出「看全部」指向该类型的列表页——聚合页处在「详情 → 标签 → ?」这条路径的中间,不给出口就是把一个断头路换成另一个。

取数在 Cms\Services\TagContent,返回结构与 SiteSearch::search() 一致,宿主要换口径 bind 掉即可。

⚠️ 加内容类型时要同时改三处:该模型的 tags() 正向关系、SiteTag 上的反向关系、TagContentgroups()hasContent()。资讯这一条长期只有前两处的第一处——SiteTag 上没有 news(),于是「从标签查内容」整整少一类,而资讯恰恰是打标签最多的。

10. 城市页

一套「按行政区划批量建落地页」的机制。前台三段层级,两套主题各三份视图:

/city                      总索引,按省分组平铺全部已发布城市
/city/{省}                 该省的城市列表
/city/{省}/{市}            城市页

⚠️ /city/{省} 有两种产出。 该省级区划自己挂了城市页就直接渲染城市页(直辖市走这条),否则渲染下辖城市列表。北京没有「下辖地级市」那一层,硬套三段会造出 /city/beijing/beijing 这种与省页内容完全一样的第二个地址。

包放机制,数据归宿主

在哪
区划表 site_regions、城市页表 site_city_pages、路由、视图、结构化数据
区划数据文件 宿主,路径由 --file 给,包不随身携带
概况表有哪些字段 宿主 config,包里默认空数组

config('filamentboot-site.city_pages.profile_fields') 声明 site_city_pages.profile 里能出现哪些字段(key / label / type / unit / help / options),后台表单与前台模板都从它生成。所以包本身不认识「气候类型」「供暖方式」这些词——那是装修行业的口径,换个行业装这个包,同一张表里该放的是别的东西。配错的条目由 Modules\Corporate\Cities\CityProfile 静默丢掉,宁可少一个字段也不在页面上渲染一个坏格子。

导入区划

php artisan filamentboot-site:import-regions --file=database/data/regions.json [--dry-run]

输入是嵌套 JSON,每个节点必须写出 level

[{"code":"420000","level":1,"name":"湖北省","short_name":"湖北","slug":"hubei","children":[
  {"code":"420100","level":2,"name":"武汉市","short_name":"武汉","slug":"wuhan","children":[
    {"code":"420102","level":3,"name":"江岸区"}]},
  {"code":"429004","level":3,"name":"仙桃市"}]}]

⚠️ 层级不能用嵌套深度推。 上面最后那条就是原因:仙桃是省直辖县级市,直接挂在湖北省下,深度 2 而层级 3;直辖市更彻底,北京的 16 个区全部直接挂在省级下。「谁的下级」和「第几级」在中国的行政区划里本来就不是一回事。

命令幂等(按 code upsert),且不删任何东西——文件里没有、库里有的记录只报出来,因为那些记录底下很可能挂着城市页。第 1、2 级必须有 slug(要进 URL),第 3 级不要求:它不建页,导进来只为渲染「下辖区县」。

slug 必须随数据给,命令不做拼音转换。 自动转一定错:陕西山西 的拼音都是 shanxi 机器分不开;地名多音字(长治 / 朝阳 / 漯河 / 昌都 / 阿勒泰…)主流拼音库也读不对。slug 是 URL 的一段,改一次就是一条死链,属于要人工确认的数据。

slug 只在同一个上级下唯一,所以 /city/jiangsu/taizhou(泰州)与 /city/zhejiang/taizhou(台州)、/city/jilin/city/jilin/jilin 都是合法且不冲突的。

页面主体是渲染出来的,不是写出来的

site_city_pages.content_zh 正常应该是 NULL。页面由模板从概况表、下辖区县、同省城市渲染——三百多个城市不该有三百多篇正文。填了它是追加在概况之后,留给「这个城市确实有值得单独说的东西」的个别情况。

模板里的共用文字刻意压到最少(只有一个 CTA,没有推荐位):这一页会被复制三百多次,每多一句写死的话就在三百多个页面上多一段一模一样的正文。

结构化数据

城市页是 Service + areaServed明确不用 LocalBusiness——那个类型要求 address,而在这三百多个城市里通常没有实体经营场所,填进去就是编一个不存在的地址。areaServed@type 按区划名判:以「市」结尾用 City,自治州 / 地区 / 盟用 AdministrativeArea。省页与总索引是 CollectionPage

站点地图分片

/sitemap.xml 变成索引,指向 /sitemap-content.xml/sitemap-city.xml。分片的理由不是体积(离 50000 条上限差着两个数量级),是站长平台按提交的分片分别统计收录率——「城市页收录了多少」这个数字混在一份地图里根本读不出来。一条城市页都没发布时索引里不出现城市分片。

11. 资料索取(手册换联系方式)

页面里放一个「资料索取」区块,上传 PDF / Word / Excel / PPT / ZIP,访客提交联系方式后拿到一条限时签名下载链接

// config/filamentboot-site.php
'gated' => [
    'disk'     => env('SITE_GATED_DISK', 'local'),   // ⚠️ 必须是非公开磁盘
    'link_ttl' => env('SITE_GATED_LINK_TTL', 30),    // 链接有效分钟数
],

⚠️ disk 指到 public 这道门就形同虚设,而且不会有任何报错:文件会多出一个人人可猜的 /storage/... 地址,表现只是「留资率莫名很低」。默认 local 指向 storage/app,在 Web 根之外。

门由四条共同关住:前台 HTML 里只有不透明 key(没有文件路径);下载必须带有效且未过期的签名;key 必须由某个已发布页面声明(草稿页的资料下不到);判为机器人时对外回成功但不放资料。

「索取了哪份资料」会记进询盘的额外问题里,后台详情、CSV 导出与通知邮件三处都能看到。

12. 询盘表单加问不同的问题

「询盘表单」与「资料索取」区块都能配额外问题(最多 6 个,单行文本 / 多行文本 / 下拉),答案存进 site_contact_messages.extra,后台、导出、通知邮件同步可见。

⚠️ 必填只在浏览器里生效。 提交端点是无状态的(公开页零 session 的连带结果),它收到的只是一份键值对,无从核对是哪份区块配置渲染出来的表单。服务端只做边界约束(条数、键长、值长)。绕过必填的代价是收到一条答得不全的线索,不是数据被污染。

13. 在线客服与地图

  • 在线客服:「网站设置 → 统计与代码 → 在线客服」,开关与代码分成两个字段,换供应商或临时无人值守时直接关掉即可。代码原样输出在 </body> 前、不过滤,仅 manage_site_settings 权限可改,变更写操作日志。移动端底部已有操作条,多数客服气泡会与它重叠,接入后请在真机上确认。
  • 地图:用「地图」区块。填的是地图服务商生成的嵌入地址(只填 src,不要整段 iframe HTML),只放行 https 且域名须精确命中 configmap.allowed_hosts(默认含百度 / 高德 / 腾讯 / Google)。文字地址建议一并填上——拦截插件、企业网络与爬虫都会丢掉 iframe,那时它是唯一还看得到的信息。

内容契约

下面这几条不是用法,是包现在的行为规格:测试照它们写,改包代码时不能绕过。定制主题、加内容类型、加区块之前先读一遍。

页面状态流转

draft → review → scheduled → published → archived
  • draft / review / scheduled 一律不得出现在公开查询里。 前台每一处取内容都走 published() scope,不是靠视图层过滤——漏一处就是草稿泄露。
  • published_at 到期之后才允许公开访问。 定时发布不是"到点改状态",是查询条件的一部分。
  • 预览必须要后台会话或短期签名 URL。 /preview/{page} 是全站唯一读未发布内容的入口,靠 auth('admin') 判权,绝不能被打上 public 标记——公开页整页缓存是共享缓存,草稿一旦进去就是对所有人可见。
  • 内容删除走软删除;SeedsBySlugwithTrashed() 查重,不复活软删过的记录——用户删它是有意的。

加一个区块要同时给齐五样

页面正文存成受控 JSON,后台用 Filament Builder 编辑。少给一样,症状都是运行时才炸:

  1. 稳定的区块键herorich-contentfeature-gridctafaq …)——键进了数据库就不能改,改了等于让已发布页面指向不存在的区块。
  2. 后台表单 Schema
  3. 前台 Blade 渲染器,落在每一套主题的 blocks/{key}.blade.php
  4. 输入验证与默认值——老页面的 payload 里不会有你新加的字段。
  5. 媒体字段的必填与 alt 文本规则

然后同步改每套主题的 theme.php 清单(见上文「主题定制」)。清单只覆盖 templates / blocks / featurescomponent 表达不了——新增跨主题组件时切换预检查抓不到"另一套主题缺这个",只能靠自己两边都写。

六条不变量

改动涉及内容发布链路时,这六条要么已有测试守着、要么你得补上:

  1. 内容编辑能建草稿但不能直接发布;发布者才能审核与发布。
  2. 未发布、未来定时发布、已归档的内容无法通过公开 URL 访问
  3. 授权用户可预览草稿,未授权用户不可
  4. 每个已发布页面都有有效 canonical、SEO 回退与站点地图条目。
  5. 案例 / 方案 / 产品 / 套餐 / 资讯 / 静态页面六类改 slug 后自动建一条 301 重定向(默认发生,通知里可撤销)。
  6. 页面里只允许主题已声明的区块,不支持任意 HTML、Blade 或 PHP 执行

安全边界

  • 富文本统一过 HTML 白名单(见上文「富文本过滤」),内容字段里不执行 Blade、PHP 或任意脚本
  • 询盘保留频率限制、蜜罐、防刷与 PII 权限控制。提交端点无状态,服务端只做边界约束。
  • 前台只查已发布内容,所有动态 slug 走参数绑定。
  • 媒体上传统一用主包的上传配置;图片要有 alt 文本、合理尺寸与封面比例约束。
  • 主题只能来自 Composer 已安装的包或宿主受控的发布目录,不支持后台上传并执行任意主题代码

架构上刻意不做的

  • 不做 Headless / 公开内容 API 撑前台。 前台是 Laravel 路由 + Controller + Blade 服务端渲染,Vite 只负责样式与 site.js
  • 不做可执行 PHP / Blade 的自由页面编辑器,也不做无限自由拖拽排版——区块必须由主题和插件显式声明。
  • 不做运行时主题市场(后台上传主题包并执行)。
  • 不把电商、会员、支付、订单一次性内置进核心。 内容域按模块隔离在 src/Modules/ 下,核心留在 src/Cms/

发布 Tag 一览

Tag 内容 目标路径
filamentboot-site-config 配置文件 config/filamentboot-site.php
filamentboot-site-views 主题视图 resources/views/vendor/filamentboot-site/themes/
filamentboot-site-assets 主题 CSS resources/css/vendor/filamentboot-site/
filamentboot-site-tests Playwright 冒烟测试(6 个 spec + global-setup.cjs)+ playwright.config.site.cjs tests/e2e/ + 项目根目录

不提供迁移 tag:内容与设置迁移由 loadMigrationsFrom() 自动加载,不走 vendor:publish(避免与自动加载重复扫描,见上文「手动模式」的说明)。

filamentboot-site-tests 刻意不进安装命令的自动发布清单(需要下游装了 Node + Playwright 才能跑),要单独执行:

php artisan vendor:publish --tag=filamentboot-site-tests
npx playwright test --config=playwright.config.site.cjs

升级

从 v0.13 升级到 v0.14:资讯摘要字段 excerpt_zh 改名为 description_zh(硬改,不留 兼容),另有一个 publish tag 被移除。升级前请先看 UPGRADING.md,按清单逐项确认。完整变更见 CHANGELOG.md

许可

MIT License,详见 LICENSE