ThinkWo 开发文档DOCS v1.0.45
快速开始
文档首页 / 模板开发 / 模板入门

模板开发

一个模板就是一组普通 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,它按顺序做四件事:

  1. 识别语言:URL 前缀决定语言(默认语言不带前缀,其它语言是 /zh-cn/…)
  2. 查跳转与缓存:先查 301 跳转表,再查静态化 HTML;命中缓存直接输出,不渲染
  3. 查数据:按路径判断是首页 / 栏目 / 详情 / 单页 / 搜索 / 留言,取对应数据
  4. 渲染模板:把模板目录指向当前主题(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_home0/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})。

七、新增一个页面模板

场景:给「关于我们」做一套带团队照片的特殊版式,不想影响其它单页。

  1. 在主题目录里新建 about-custom.html,从 page.html 复制一份再改结构
  2. 后台 → 栏目管理 → 编辑该栏目 → 单页模板 下拉里选 about-custom
  3. 保存 → 清缓存 → 访问该栏目
下拉选项从哪来的

扫描 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'"

十一、交付前自检

  1. 7 个固定页面文件齐全,theme.json 存在且 JSON 合法
  2. 所有资源都用 tw_asset() 或 /template/{$theme}/ 开头,没有相对路径
  3. 正文输出带 |html_entity_decode|raw
  4. 列表页有 page="$tw_page" + {tw:pagination /}
  5. 空列表、无图、无电话等空值情况有兜底,页面不塌
  6. 手机宽度下横向不出现滚动条
  7. 切换语言、切换货币、点二级菜单都正常
  8. 后台改一条内容 → 清缓存 → 前台确认变化

标签参数细节见 标签与函数;想查「后台某个输入框对应哪个变量」,见 后台 ↔ 变量对照。

ThinkWo CMS v1.0.45 开发文档 · 基于 ThinkPHP 8.1.4 + LayUI 2.12 · 文档随代码更新