模板开发
一个模板就是一组普通 HTML 文件,里面多写了两种东西:{tw:xxx} 标签和 {$变量}。不需要写 PHP,也不需要装 Node;把文件放进正确目录,后台选一下就能用。
下面每一节都带可直接复制运行的代码:目录怎么摆 → 每个页面文件管什么 → 有哪些变量可以取 → 三个完整骨架 → 怎么加自己的页面 → 怎么用自定义字段。全部是通用写法,与具体版式无关。
一、目录规则(最容易出错的地方)
模板被拆成「视图」和「静态资源」两个位置,必须同名成对出现:
| 类型 | 磁盘位置 | 说明 |
|---|---|---|
| 视图 HTML |
网站根目录/template/{主题名}/ |
不对外访问,由 PHP 渲染后输出 HTML |
| 静态资源 CSS/JS/图片 |
网站根目录/public/template/{主题名}/ |
对外可访问,URL 是 /template/{主题名}/css/style.css |
| 主题信息 JSON |
网站根目录/template/{主题名}/theme.json |
必需文件,后台主题列表只认带它的目录 |
网站根目录/
├─ template/
│ └─ mytheme/ ← 视图,主题名 = mytheme
│ ├─ theme.json ← 必须有
│ ├─ index.html
│ ├─ lists.html
│ ├─ detail.html
│ ├─ page.html
│ ├─ search.html
│ ├─ message.html
│ ├─ 404.html
│ └─ comm/
│ ├─ head.html
│ ├─ header.html
│ ├─ footer.html
│ └─ inquiry.html
└─ public/
└─ template/
└─ mytheme/ ← 资源,URL = /template/mytheme/...
├─ css/style.css
├─ js/main.js
└─ img/
① 主题目录名只允许字母、数字、下划线、短横线(a-z A-Z 0-9 _ -),中文和空格会被过滤掉;
② 少了 theme.json,后台「站点主题」下拉框里不会出现这个主题;
③ 视图目录和资源目录必须同名,改名要一起改。
theme.json 就四个字段,写成这样即可:
{
"name": "My Theme",
"version": "1.0.0",
"author": "你的名字",
"description": "一句话说明这套模板的适用场景",
"thumb": "/template/mytheme/thumb.jpg"
}
二、每个页面文件管什么
文件名的含义是系统约定的,改了名字对应页面就找不到模板(会 404)。只有下面这 7 个是固定入口,其它名字都算「自定义页面模板」,需要到栏目里手动指定。
| 文件 | 何时被使用 | 页面里能拿到的变量 |
|---|---|---|
index.html | 网站首页 | 全局变量(见第四节) |
lists.html | 栏目列表页、分页 | $sort、$tw_page、$position、$canonical |
detail.html | 内容详情页 | $content、$sort、$position、$prev、$next、$canonical |
page.html | 单页栏目(只有一个内容) | $sort、$content、$position |
search.html | 站内搜索 /search?kw=xxx | $keyword、$tw_page、$position |
message.html | 留言 / 询盘页 | $position + 全局 |
404.html | 找不到页面(返回真 404 状态码) | $is_404 + 全局 |
comm/ 目录没有命名强制,但建议按职责拆,方便所有页面共用:
| 片段 | 放什么 |
|---|---|
comm/head.html | <head> 里的一切,含一行 {:tw_seo()} 输出标题/canonical/hreflang/结构化数据 |
comm/header.html | 顶部导航、语言与货币切换、logo |
comm/footer.html | 页脚、版权、备案号、统计代码 |
comm/inquiry.html | 询盘表单(可被首页、详情页、联系页重复引入) |
comm/product-card.html | 列表里循环用的卡片,一处改、多页生效 |
三、渲染流程
所有前台请求都进 app/index/controller/Dispatch.php,它按顺序做四件事:
- 识别语言:URL 前缀决定语言(默认语言不带前缀,其它语言是
/zh-cn/…) - 查跳转与缓存:先查 301 跳转表,再查静态化 HTML;命中缓存直接输出,不渲染
- 查数据:按路径判断是首页 / 栏目 / 详情 / 单页 / 搜索 / 留言,取对应数据
- 渲染模板:把模板目录指向当前主题(
Theme::path()),把变量注入后输出
请求 /products/cnc-housing.html
→ 语言 en
→ 查缓存 runtime/html/en/products/cnc-housing.html(命中则直出)
→ 查栏目 products → 查内容 cnc-housing
→ 渲染 template/{当前主题}/detail.html(栏目里指定了别的模板就用指定的)
列表页 lists / 详情页 detail / 单页 page 都遵循同一条规则:栏目里指定的模板名优先,没指定才用默认文件名。所以「产品用一套版式、新闻用另一套」只需在栏目编辑页分别选模板。
四、模板里能用的变量
4.1 全局变量(每个页面都有)
| 变量 | 类型 | 说明 |
|---|---|---|
$site | 数组 | 站点设置全量,见下表 |
$theme | 字符串 | 当前主题目录名,拼资源路径用 |
$navs | 数组 | 一级导航(最多 12 条),用于页头 |
$langs | 数组 | 所有启用语言,用于语言切换 |
$lang | 字符串 | 当前语言代码,如 en、zh-cn |
$region | 字符串 | 语言对应地区码,写进 <html lang> |
$rtl | 布尔 | 是否从右到左(阿拉伯语等),用于 dir 属性 |
$currencies | 数组 | 启用的货币,用于货币切换 |
$is_home | 0/1 | 是否首页,导航高亮判断用 |
$inq_captcha | 布尔 | 询盘验证码是否开启,关闭时表单别输出验证码行 |
$site 常用键(全部来自后台「站点设置」,没填的已由程序兜底为空串,不会报未定义):
site_name site_title company site_description site_keywords
logo logo_text favicon copyright site_icp
phone email address whatsapp currency
social_facebook social_linkedin social_youtube
og_image analytics_code head_code footer_code seo_index_title
4.2 分页变量 $page
在任何 {tw:list} 之后,模板里就自动多了两个变量(不需要你赋值):
| 变量 | 含义 |
|---|---|
$page.current | 当前页码 |
$page.last | 总页数 |
$page.total | 记录总数 |
$page.limit | 每页条数 |
$page.has_prev / $page.has_next | 是否有上一页 / 下一页 |
$total | 记录总数(与 $page.total 相同,单独给「共 N 条」用) |
4.3 $sort(栏目)字段
id pid name slug type description
ico(缩略图) pic(栏目大图 / 内页 banner)
url(栏目链接) children(子栏目数组) son(是否有子栏目 0/1)
seo_title seo_description seo_keywords canonical slide_group
template_list template_detail template_page
subname(副标题:数据库有这一列,后台栏目表单未提供输入框,通常为空)
4.4 $content / $list(内容)字段
| 字段 | 说明 |
|---|---|
id title subtitle slug | 基础信息 |
summary | 摘要(注意不是 description,栏目才有 description) |
content | 正文 HTML,输出要加 |html_entity_decode|raw |
ico | 缩略图(上传时已自动转 WebP 并生成多尺寸) |
images | 图集,数组(仅详情页有) |
tags / tag_list | 标签字符串 / 标签数组(tag_list 仅详情页有) |
price unit moq model_no attr | 价格、单位、起订量、型号、属性 |
date / datetime | 发布时间:Oct 30, 2026 / 2026-10-30(给 <time datetime> 用) |
url | 详情页链接(自动生成) |
lines | 正文按行拆成的数组,做「功能清单」很方便 |
sort_name sort_url sort_slug | 所属栏目信息 |
cf | 栏目自定义字段合集(数组),见第八节 |
| 你的字段键 | 自定义字段可直接当普通字段用,如 {$list.delivery} |
{tw:list} 循环里拿到的是精简字段(上表除 images、tag_list 外都有);详情页的 $content 是整行数据,还多出 images、tag_list、sort(整行栏目)。在列表里想要图集或标签数组,改用 {tw:content} 或在控制器层处理。
五、语法速查
5.1 引入公共片段
{include file="comm/head" /}
{include file="comm/header" /}
<main>
...
</main>
{include file="comm/footer" /}
不带后缀,路径相对当前主题目录,所以 comm/head 就是 template/{主题}/comm/head.html。include 是自闭合标签,只有 {include ... /} 这一种写法。
5.2 条件判断
{if !empty($site['phone'])}
<a href="tel:{$site.phone}">{$site.phone}</a>
{else /}
<span>电话待补充</span>
{/if}
{* 导航是否有子栏目 *}
{if $nav.son > 0}<i class="arrow"></i>{/if}
{* 自定义字段是否为开关打开 *}
{if $list.recommend == '1'}<em>推荐</em>{/if}
5.3 循环
{* 内容列表:alias 是循环变量名,key 是下标 *}
{tw:list sort="products" limit="12" alias="item"}
<h3>{$item.title}</h3>
{/tw:list}
{* 普通数组:name 不带 $ *}
{volist name="content.images" id="img"}
<img src="{$img}" alt="{$content.title}">
{/volist}
{* 要拿数组键名时必须用 foreach,volist 的 key 是序号 *}
{foreach name="list.cf" item="v" key="k"}
<span>{$k} = {$v}</span>
{/foreach}
5.4 输出正文
{$content.content|html_entity_decode|raw}
{$list.summary|raw}
模板默认转义所有输出。正文不加 |html_entity_decode|raw,页面上会直接显示 <p> 标签源码。
5.5 过滤器与函数
{$list.summary|substr=0,80} {* 截取字符 *}
{$list.date|date='Y-m-d'} {* 时间格式化 *}
{$site.site_name|strtoupper} {* 转大写 *}
{$site.logo|default=''} {* 默认值:只接受常量 *}
{$list.price|number_format=2} {* 数字格式 *}
{* 函数调用统一用 {: } 包裹 *}
{:tw_cut($content.content, 120)}
{:tw_asset('css/style.css')}
{:tw_url('search')}
5.6 注释
{* 这样写注释,前台不会输出 *}
<!-- 这种 HTML 注释会出现在源码里 -->
六、三个可直接抄的骨架
下面三个骨架是最常用的三块,复制到对应文件里改 class 就是你的版式。
6.1 栏目列表页(含分页)
{include file="comm/head" /}
{include file="comm/header" /}
<section class="banner">
<h1>{$sort.name}</h1>
{if $sort.description}<p>{$sort.description}</p>{/if}
</section>
<div class="wrap">
<ul class="grid">
{tw:list sort="$sort.slug" limit="12" page="$tw_page" alias="item"}
<li>
<a href="{$item.url}">
{if $item.ico}<img src="{$item.ico}" alt="{$item.title}" loading="lazy">{/if}
<h3>{$item.title}</h3>
<p>{$item.summary|substr=0,80}</p>
<time datetime="{$item.datetime}">{$item.date}</time>
</a>
</li>
{/tw:list}
</ul>
{* 一条内容都没有时的兜底 *}
{if empty($page.total)}
<p class="empty">这个栏目还没有发布内容。</p>
{/if}
{tw:pagination /}
</div>
{include file="comm/footer" /}
① {tw:list} 上加 page="$tw_page";② 循环结束后用 {tw:pagination /} 输出分页条;③ 想自己写分页样式,直接读 $page.current / $page.last。三个条件缺一个分页就不动。
6.2 内容详情页
{include file="comm/head" /}
{include file="comm/header" /}
<article class="wrap">
<h1>{$content.title}</h1>
<p class="meta">
<time datetime="{$content.datetime}">{$content.date}</time>
{if $content.sort_name}
<a href="{$content.sort_url}">{$content.sort_name}</a>
{/if}
</p>
{if $content.ico}
<img class="cover" src="{$content.ico}" alt="{$content.title}">
{/if}
<div class="prose">
{$content.content|html_entity_decode|raw}
</div>
{* 图集 *}
{if !empty($content['images'])}
<div class="gallery">
{volist name="content.images" id="img"}
<img src="{$img}" alt="{$content.title}" loading="lazy">
{/volist}
</div>
{/if}
{* 标签 *}
{if !empty($content['tag_list'])}
<p class="tags">
{volist name="content.tag_list" id="tag"}<span>{$tag}</span>{/volist}
</p>
{/if}
{* 上一篇 / 下一篇 *}
<nav class="nearby">
{if $prev}<a href="{$prev.url}">← {$prev.title}</a>{/if}
{if $next}<a href="{$next.url}">{$next.title} →</a>{/if}
</nav>
</article>
{include file="comm/inquiry" /}
{include file="comm/footer" /}
6.3 首页的一个版块(读指定栏目)
{* 取 3 条推荐产品;sort 写栏目 ID 或 slug 都行 *}
<section class="home-products">
<div class="head">
<h2>主推产品</h2>
<a href="{:tw_url('products')}">查看全部 →</a>
</div>
<ul class="grid">
{tw:list sort="products" limit="3" alias="p"}
<li>
<a href="{$p.url}">
{if $p.ico}<img src="{$p.ico}" alt="{$p.title}">{/if}
<h3>{$p.title}</h3>
{if $p.price}<b>{$p.price} {$p.unit}</b>{/if}
</a>
</li>
{/tw:list}
</ul>
</section>
{* 取某个栏目的信息(标题、描述、大图) *}
{tw:sort slug="about" alias="about"}
<section class="home-about">
<h2>{$about.name}</h2>
<p>{$about.description}</p>
{if $about.pic}<img src="{$about.pic}" alt="{$about.name}">{/if}
</section>
6.4 页头导航(含二级菜单与高亮)
<header class="hd">
<a class="logo" href="{:tw_url('')}">
{if $site.logo}
<img src="{$site.logo}" alt="{$site.site_name}">
{else}
<b>{$site.logo_text}</b>
{/if}
</a>
<nav class="nav">
{tw:nav parent="0" alias="nav"}
<div class="nav-item{if $nav.active} on{/if}">
<a href="{$nav.url}">{$nav.name}</a>
{if $nav.son > 0}
<div class="sub">
{tw:nav parent="$nav.scode" alias="nav2"}
<a href="{$nav2.url}">{$nav2.name}</a>
{/tw:nav}
</div>
{/if}
</div>
{/tw:nav}
</nav>
{* 语言切换:当前语言用 $lang 比对 *}
{if count($langs) > 1}
<div class="langs">
{volist name="langs" id="l"}
<a href="{$l.url}"{if $l.code == $lang} class="on"{/if}>{$l.name}</a>
{/volist}
</div>
{/if}
</header>
$nav 可用:id、name、subname、slug、url、ico、scode(子栏目查询用)、son(0/1)、active(当前栏目高亮,直接写 {$nav.active})。
七、新增一个页面模板
场景:给「关于我们」做一套带团队照片的特殊版式,不想影响其它单页。
- 在主题目录里新建
about-custom.html,从page.html复制一份再改结构 - 后台 → 栏目管理 → 编辑该栏目 → 单页模板 下拉里选
about-custom - 保存 → 清缓存 → 访问该栏目
扫描 template/{当前主题}/ 下的顶层 .html 文件,文件名去掉后缀就是选项值(404 / search / message 三个固定页面已排除)。文件不在主题目录里,下拉里就不会有它。保存时服务端会再校验一次:只允许 a-z A-Z 0-9 _ -,且文件必须存在,否则回退成默认模板。
栏目里指定的自定义模板只在当前主题内有效。换主题后如果新主题没有同名文件,该栏目会回退到默认的 lists / detail / page,需要重新逐个栏目确认。
八、栏目自定义字段
想在某个栏目多加几个输入框(交期、是否推荐、规格表…),不改表、不改程序:字段定义挂在栏目上,值以 JSON 存在内容的 meta 列,模板按字段键直接取。
后台:栏目管理 → 目标栏目「字段」→ 定义
字段名称=交期 字段键=delivery 类型=单行文本
字段名称=推荐 字段键=recommend 类型=开关
后台:内容管理 → 编辑该栏目下的内容 → 自动出现这两个输入框
模板:{$list.delivery} {$content.delivery}
{if $list.recommend == '1'}推荐{/if}
| 类型 | 后台控件 | 存什么 / 怎么写 |
|---|---|---|
text | 单行输入框 | 纯文本 → {$list.键} |
textarea | 多行文本 | 带换行 → {$list.键|nl2br} |
select | 下拉(选项一行一个) | 选中的文字(不是序号)→ {if $list.键 == '7 天'} |
switch | 开关 | '1' / '0' → {if $list.键 == '1'} |
{* 遍历一个内容的全部自定义字段:必须 foreach *}
{foreach name="list.cf" item="v" key="k"}
<dt>{$k}</dt><dd>{$v}</dd>
{/foreach}
① 遍历字段用 {foreach},{volist} 的 key 输出的是序号 1、2、3,不是字段键;
② 字段键不能用系统保留名:title、summary、content、meta、url、lines、cf 等会被拒绝。完整规则见 栏目自定义字段。
九、缓存与生效规则
| 改了什么 | 要不要清缓存 |
|---|---|
| 模板 HTML | 不用。编译缓存会校文件修改时间,刷新即生效 |
| CSS / JS | 不用,但浏览器要强刷(Ctrl+F5)或给文件名加版本号 |
| 后台内容 / 站点设置 | 要清。静态化页面是纯 HTML,不清还是旧内容 |
| 换主题 | 要清,旧主题的页面缓存在 runtime/html/ |
| PHP 代码 | 要清(runtime/cache/ 里有设置与菜单缓存) |
清缓存入口:后台右上角「清缓存」,或 后台 → SEO 工具 → 清理静态化缓存。
后台 → SEO 工具里把「静态化」先关掉,改一处刷新就能看到效果;上线前再打开。开着静态化时每次改内容都要清缓存,开发效率很低。
十、踩坑清单
| 写法 | 为什么错 / 正确写法 |
|---|---|
{$list.description} | 列表里没有这个字段,摘要用 {$list.summary} |
{$list.pagebar} | 不存在。分页用 {tw:pagination /},数据在 $page |
{tw:list sort="1" page="2"} 想手写页码 | 能跑,但列表页要传 page="$tw_page" 才会跟着 URL 变 |
{volist name="$content.images" ...} | 错。name 不带 $,写 name="content.images" |
{$content.content} | 会显示 HTML 源码,必须 |html_entity_decode|raw |
{$site.company|default=$site.site_name} | 错。default 只接受常量,不支持点语法。用 {if} 判断 |
{if $langs|count > 1} | 错。条件里不用过滤器,写 {if count($langs) > 1} |
{extend name="base" file="base" /} | 错。extend 只认 name;本系统用 include 拼页面,不需要母版继承 |
src="css/style.css" | 错。内页是多层 URL,相对路径会 404。写 {:tw_asset('css/style.css')} 或 /template/{$theme}/css/style.css |
模板里写 {$a - 1} | 可能被当标签解析,改用 {:($a - 1)} |
| 标签属性里嵌双引号 | 用单引号:order="'id desc'" |
十一、交付前自检
- 7 个固定页面文件齐全,
theme.json存在且 JSON 合法 - 所有资源都用
tw_asset()或/template/{$theme}/开头,没有相对路径 - 正文输出带
|html_entity_decode|raw - 列表页有
page="$tw_page"+{tw:pagination /} - 空列表、无图、无电话等空值情况有兜底,页面不塌
- 手机宽度下横向不出现滚动条
- 切换语言、切换货币、点二级菜单都正常
- 后台改一条内容 → 清缓存 → 前台确认变化
标签参数细节见 标签与函数;想查「后台某个输入框对应哪个变量」,见 后台 ↔ 变量对照。