模版开发文档

这份开发文档已经按当前模板引擎、模板编辑器校验规则,以及当前系统启用的 CSP 策略重新整理。文档里的语法、标签、示例和限制,和现在后台真实可保存、可解析、可上线的模板写法一一对应。

先确认模板结构 当前推荐结构是 head → 页面自己的 CSS → top → 内容 → 页面自己的 JS → foot,资源直接写在模板里。
再按“栏目 / 内容 / 图宣”选标签 列表页先找查询标签,详情页再配合上一篇、下一篇和相关文章这类辅助标签。条件和循环只支持当前引擎列出的基础能力。
CSP 下不要写内联代码 模板源码里不能写内联 <script><style>style=on* 事件属性,样式和脚本都必须走当前主题资源。

基础语法与限制

先看这一节

模板只负责展示,不写 PHP,也不支持复杂表达式。当前模板引擎真正支持的只有:输出、赋值、条件、循环、公共模板引用和内置标签调用。

语法 用途 示例
{{ ... }} 输出变量、字段或调用结果,默认会做 HTML 转义。 {{ current.channel.name }}
{{{ ... }}} 输出原始 HTML,仅适合已经由系统处理过的安全内容。 {{{ current.content.content_html }}}
{% set ... %} 给变量赋值。右侧可以是标签调用、变量、字符串、数字、布尔值。 {% set articles = contentList(channel='news', limit=6)%}
{% if ... %} 只支持基础判断:变量真假、not==!= {% if current.page.type == 'home' %}...{% endif %}
{% for ... %} 遍历数组或集合,格式固定为 for item in list {% for item in articles %}...{% endfor %}
{% include "..." %} 引用公共模板片段,模板名只能用字母、数字、下划线、短横线。 {% include "top" %}
{% include "head" %}
{{ themeStyle(path='theme.css') }}
{{ themeStyle(path='home.css') }}
{% include "top" %}

{% set articles = contentList(channel='news', limit=6)%}
{% if articles %}
  {% for item in articles %}
    <a href="{{ item.url }}">{{ item.title }}</a>
  {% endfor %}
{% endif %}

{{ themeScript(path='home.js') }}
{% include "foot" %}
  • 参数写法:支持位置参数和 key=value 命名参数两种方式;参数名与含义以文档示例为准。
  • 模板名有格式限制:模板名和 include 引用名只支持字母、数字、短横线和下划线。
  • 通用标签模板约定:复用片段统一使用 tag-xxx.tpl 命名,在业务模板中通过 {% include "tag-xxx" %} 直接调用。
  • 后台创建模板类型:模板工作台新增 Tag 模板 类型,创建时会生成 tag-标识.tpl 文件,适合跨列表页、详情页、单页复用同一段结构。
  • 条件能力是有限的:不支持括号、三元表达式、数组字面量、函数调用和任意 JS / PHP 风格表达式。
  • 模板里不允许写 PHP:模板源码中不能出现 PHP 代码标签,保存时后端会直接拦截。

模板资源与 CSP

当前上线规则

当前系统为了让 style-src 'self'script-src 'self' 完全成立,模板资源必须全部走当前主题目录的 assets/。模板里直接写引入即可,系统会自动生成当前站点、当前主题可访问的同源地址。

调用 作用 示例
themeAsset(path='...') 返回当前主题资源地址,适合图片、字体、JSON 等资源。 {{ themeAsset(path='assets/banner.jpg') }}
themeStyle(path='...') 输出 <link rel="stylesheet">,用于加载主题 CSS。 {{ themeStyle(path='theme.css') }}
themeScript(path='...') 输出 <script src="..."></script>,用于加载主题 JS。 {{ themeScript(path='home.js') }}
{% include "head" %}
{{ themeStyle(path='theme.css') }}
{{ themeStyle(path='landing.css') }}
{% include "top" %}

<section class="hero">
  <img src="{{ themeAsset(path='assets/hero-cover.jpg') }}" alt="封面图">
</section>

{{ themeScript(path='landing.js') }}
{% include "foot" %}
  • 资源写法:直接在模板里写 themeStyle / themeScript / themeAsset
  • 资源必须属于当前主题:path 指向的文件必须真实存在于当前主题里,否则模板保存校验会报错。
  • 站点资源不能再直接引用:模板里不允许再写 /site-media/... 或旧的资源库地址;模板专用图片、字体、JSON 请放在当前主题的 assets/ 目录。
CSP 为什么要这样写 当前站点使用严格同源策略:style-src 'self'script-src 'self'。这样做是为了让模板规范更标准,也让网站前后台保持更安全的运行环境。
保存前最容易踩坑的地方 以下写法会在模板保存时被直接拦截:内联 <script>、内联 <style>、任何 style=、任何 onclick 这类内联事件属性。正确做法是:HTML 里只保留类名和结构,交互统一写进主题 JS,样式统一写进主题 CSS。

Tag 通用模版标签

跨页面复用首选

tag-xxx.tpl 是通用标签模板,适合把“同一段结构”抽成一个片段,在列表页、详情页、单页中复用。当前系统已在模板工作台提供 Tag 模板 类型,创建后会生成 tag-标识.tpl

约定 说明 示例
文件命名 统一使用 tag- 前缀,便于模板树识别和团队协作。 tag-article-cards.tpl
调用方式 直接用 include,不新增语法,不改引擎。 {% include "tag-article-cards" %}
参数传递 调用前先 set 变量,建议统一前缀 tag_,避免变量冲突。 {% set tag_items = contentList(channel='news', limit=6)%}
<!-- 这段是“调用方模板”示例:无论是列表页、详情页还是单页模板,都可以先准备参数再引入公共片段 -->
{% set tag_title = '最新动态' %}
{% set tag_items = contentList(channel_id=current.channel.id, limit=6)%}
{% include "tag-article-cards" %}
<!-- 这段是“被复用的片段模板”:专门负责把外部传进来的标题和文章列表渲染成统一样式 -->
<section class="news-cards">
  <h2>{{ tag_title }}</h2>
  {% if tag_items %}
    <div class="news-cards-grid">
      {% for item in tag_items %}
        <article class="news-card">
          <a href="{{ item.url }}">{{ item.title }}</a>
        </article>
      {% endfor %}
    </div>
  {% else %}
    <div class="news-cards-empty">暂无内容</div>
  {% endif %}
</section>
  • Tag 模板只放结构:样式和交互建议由页面统一通过 themeStyle / themeScript 引入,避免重复加载。
  • 变量要防冲突:建议统一使用 tag_ 前缀(如 tag_itemstag_title)。
  • 禁止循环 include:tag 片段不要反向引用调用方模板,避免 include 循环。
  • 参数应可兜底:片段内部建议先判断变量是否为空,避免在不同页面上下文中出现空数据异常。

站点信息

固定资料统一从这里取

siteValue 统一负责网站固定资料取值。网站名称、Logo、联系方式、备案号、简介和 SEO 信息都从这里取。

{{ siteValue(key='name', default='网站名称')}}
{{ siteValue(key='contact_phone', default='暂无电话')}}
{{ siteValue(key='icp_no', default='未备案')}}
写法 说明 示例
id 站点 ID,适合调试或特殊判断。 {{ siteValue(key='id')}}
name 固定站点资料:网站名称。 {{ siteValue(key='name', default='网站名称')}}
logo 网站 Logo 地址。 {{ siteValue(key='logo')}}
favicon 网站 favicon 地址。 {{ siteValue(key='favicon')}}
contact_phone 固定站点资料:联系电话。 {{ siteValue(key='contact_phone', default='暂无电话')}}
contact_email 固定站点资料:联系邮箱。 {{ siteValue(key='contact_email', default='暂无邮箱')}}
address 固定站点资料:联系地址。 {{ siteValue(key='address', default='暂无地址')}}
icp_no 固定站点资料:备案号。 {{ siteValue(key='icp_no', default='未备案')}}
remark 网站简介或备注。 {{ siteValue(key='remark', default='这里放网站简介')}}
seo_title 固定站点资料:SEO 标题。 {{ siteValue(key='seo_title', default='网站名称')}}
seo_keywords 固定站点资料:SEO 关键词。 {{ siteValue(key='seo_keywords')}}
seo_description 固定站点资料:SEO 描述。 {{ siteValue(key='seo_description')}}
home_url 网站首页地址。 {{ siteValue(key='home_url')}}
<div class="site-meta">
  <span>电话:{{ siteValue(key='contact_phone', default='暂无电话')}}</span>
  <span>地址:{{ siteValue(key='address', default='暂无地址')}}</span>
  <span>备案:{{ siteValue(key='icp_no', default='未备案')}}</span>
</div>

<title>{{ siteValue(key='seo_title', default='网站名称')}}</title>
<meta name="keywords" content="{{ siteValue(key='seo_keywords')}}">
<meta name="description" content="{{ siteValue(key='seo_description')}}">

页面 SEO 变量(推荐)

前台模板统一提供 meta 变量,适合直接写在 head.tpl。系统会按“当前页面优先,站点默认兜底”自动取值:

  • meta.title:当前页 SEO 标题(无则回退站点 SEO 标题/站点名称)。
  • meta.keywords:当前页 SEO 关键词(无则回退站点 SEO 关键词)。
  • meta.description:当前页 SEO 描述(无则回退站点 SEO 描述/站点名称)。
<title>{{ meta.title }}</title>
<meta name="keywords" content="{{ meta.keywords }}">
<meta name="description" content="{{ meta.description }}">

当前上下文

判断当前页面正在展示什么

用来判断当前页面正在展示什么。标题、面包屑、详情页上下文通常都从这里取。

参数 说明 示例
current.page.type 当前页面类型,常见值有 homechannelarticlepage {% if current.page.type == 'home' %}...{% endif %}
current.page.scope 当前页面作用域。 {{ current.page.scope }}
current.page.template_name 当前页面模板名。文章和单页面都支持在内容编辑页单独选择模板;如果内容本身没有单独绑定,就会回退到所属栏目的默认模板。 {{ current.page.template_name }}
current.page.keyword 当前请求中的搜索关键词(已做安全处理和长度限制),适合用于列表页搜索框回显,并传入 contentPage(keyword=...) 做服务端分页搜索。 {{ current.page.keyword }}
current.page.show_all 当前栏目页是否处于“全部内容”模式。访问栏目地址并带 all=1 时为 true,可用于“全部内容”按钮高亮和栏目高亮切换。 {% if current.page.show_all %}...{% endif %}
current.channel.id 当前栏目 ID。 {{ current.channel.id }}
current.channel.name 当前栏目名称。 {{ current.channel.name }}
current.channel.slug 当前栏目别名。 {{ current.channel.slug }}
current.channel.is_active 当前栏目是否处于激活态。通常用于栏目本身列表中的选中样式判断;导航高亮更推荐直接使用 navItem.is_active {% if current.channel.is_active %}...{% endif %}
current.content.id 当前内容 ID。 {{ current.content.id }}
current.content.title 当前内容标题,详情页最常用。 {{ current.content.title }}
current.content.view_count 当前内容点击数,可直接在详情页显示阅读量。 {{ current.content.view_count }}
current.content.channel_id 当前内容所属栏目 ID,可配合详情页辅助标签使用。 {{ current.content.channel_id }}

栏目参数

导航、子栏目、面包屑

适合做导航、子栏目入口、面包屑、栏目切换和栏目定向展示。

模板优先级也和栏目有关:文章详情页默认继承栏目详情模板,单页面默认继承单页栏目模板;如果在内容编辑页里给某一篇文章或某一个单页面单独选择了模板,就会优先使用内容自己的模板。

调用 / 参数 说明 示例
channel 取单个栏目。不传参数时默认取当前栏目。 {% set currentChannel = channel() %}
channel id / slug 按栏目 ID 或栏目别名取单个栏目。 {% set currentChannel = channel(slug='news')%}
children 取直属子栏目。若在 {% for root in rootChannels %} 循环里使用,root.id 就是“当前循环到的一级栏目 ID”。 {% set childChannels = children(channel_id=current.channel.id, limit=8)%}
parent 取当前栏目的上级栏目。 {% set parentChannel = parent(channel_id=current.channel.id)%}
siblings 取同级栏目。 {% set siblingChannels = siblings(channel_id=current.channel.id, limit=6)%}
breadcrumb 取面包屑路径。 {% set trail = breadcrumb(channel_id=current.channel.id)%}
linkTo 统一生成栏目、文章、单页和首页地址。 {{ linkTo(type='channel', slug=current.channel.slug)}}
linkTo home / channel / article / page 支持首页、栏目、文章、单页四种链接类型,也可配合 idchannel_idslugdefault 使用。 {{ linkTo(type='home')}}
channels parent_id / status / type / slug 按父级、状态、类型或别名筛选栏目。 {% set pageChannels = channels(type='page', status=1, limit=6)%}
channels(is_nav=true) 取导航栏目。 {% set navChannels = channels(is_nav=true, limit=8)%}
channels(is_nav=true, type='list') 导航里只调用“栏目型”节点(列表栏目)。 {% set navListChannels = channels(is_nav=true, type='list', status=1, limit=8)%}
channels(is_nav=true, type='page') 导航里只调用“单页型”节点。 {% set navPageChannels = channels(is_nav=true, type='page', status=1, limit=8)%}
栏目页全部内容(all=1) 在栏目页 URL 增加 all=1 后,列表查询会按当前站点全量文章分页,不再限制为当前栏目,可配合 current.page.show_all 做样式高亮。 <a href="/cat/{{ current.channel.slug }}?site={{ site.site_key }}&all=1">全部内容</a>
channels include_ids 只返回指定栏目 ID。 {% set pickedChannels = channels(include_ids='2,3,4', limit=3)%}
channels exclude_ids 排除某些栏目 ID。 {% set list = channels(exclude_ids='6,7')%}
channels keyword 按栏目名称搜索。 {% set searchedChannels = channels(keyword='公告', limit=6)%}
channels fields 只返回指定字段,系统会保留 id {% set liteChannels = channels(fields='name,url', limit=5)%}
channels random 随机返回栏目顺序。 {% set randomChannels = channels(random=true, limit=4)%}

内容参数

列表页最常用

内容查询是模板里最常用的一组能力,适合列表页、首页资讯区、固定推荐和随机推荐。需要分页时请优先使用 contentPage,它会返回 itemspagination 两段数据。

调用 / 参数 说明 示例
content 按内容 ID 取单条内容。 {% set notice = content(id=12)%}
contentList type / status 按内容类型和状态筛选,默认文章会取已发布内容。 {% set pages = contentList(type='page', status='published', limit=6)%}
contentPage page / per_page / page_name / window 分页查询。返回 result.itemsresult.paginationpage_name 默认 pagewindow 控制分页条左右页码数量。 {% set result = contentPage(channel='news', per_page=10, page=2, page_name='page', window=2)%}
contentPage keyword 服务端关键词搜索(标题 + 摘要)并支持分页,推荐用于列表页搜索。 {% set result = contentPage(type='article', keyword=current.page.keyword, per_page=5, page_name='page', window=2)%}
linkTo 内容地址统一用 linkTo 生成。 {{ linkTo(type='article', id=12)}}
contentList channel 按栏目别名取列表。 {% set articles = contentList(channel='news', limit=6)%}
contentList channel_id 按栏目 ID 取列表。 {% set articles = contentList(channel_id=current.channel.id, limit=6)%}
contentList channel_slug 按栏目别名筛选内容。 {% set articles = contentList(channel_slug='news', limit=6)%}
offset 跳过前几条内容。 {% set secondGroup = contentList(channel='news', limit=4, offset=4)%}
with_cover 只取带封面图的内容,和 has_image 作用一致。 {% set coverArticles = contentList(channel='news', with_cover=true, limit=4)%}
has_image 只取有封面图的内容。 {% set imageArticles = contentList(channel='news', has_image=true, limit=4)%}
keyword 按标题和摘要模糊搜索。 {% set filteredArticles = contentList(keyword='公告', limit=6)%}
published_after / published_before 按发布时间范围筛选。 {% set list = contentList(published_after='2026-04-01 00:00:00', published_before='2026-04-30 23:59:59')%}
order_by / order_dir 自定义排序字段和方向。 {% set list = contentList(order_by='published_at', order_dir='desc', limit=6)%}
order 使用预设排序,例如 published_at_descupdated_at_descid_desc {% set list = contentList(order='updated_at_desc', limit=6)%}
include_ids / exclude_ids 定向包含或排除内容。 {% set focusArticles = contentList(include_ids='12,15,18', limit=3)%}
author / source 按作者、来源筛选。 {% set officeArticles = contentList(author='教务处', source='官网', limit=4)%}
is_top / is_recommend 按置顶或推荐标记筛选。 {% set topArticles = contentList(channel='news', is_top=true, limit=5)%}
random 随机返回内容。 {% set randomArticles = contentList(channel='news', random=true, limit=3)%}
fields 只返回指定字段,系统会保留 id {% set liteArticles = contentList(fields='title,url,published_at', limit=4)%}

搜索交互建议:列表页应在用户手动确认后(回车或点击搜索按钮)再提交 keyword,并由 contentPage 返回全量命中结果与分页;不建议仅用前端脚本筛当前页已渲染内容。

图宣参数

轮播、横幅、视觉位

适合首页轮播、悬浮图、横幅区和模板专属视觉位。

调用 / 参数 说明 示例
promo 取单条图宣,可直接写 code {% set banner = promo(code='home_banner')%}
promos 取多条图宣。 {% set banners = promos(code='home_banner', limit=5)%}
promo / promos 直接写 code 图宣位编码是最常用的入口,也可以直接把编码作为首个值传入。 {% set banner = promo(code='home_banner') %}
code 图宣位编码,最常用。 {% set banners = promos(code='home_banner')%}
display_mode 按展示模式筛选。 {% set floats = promos(display_mode='floating', limit=2)%}
page_scope 按页面作用域筛选。 {% set list = promos(page_scope='home')%}
channel_id / channel_slug 按栏目范围筛选。 {% set list = promos(channel_slug=current.channel.slug)%}
template_name 按模板名筛选。 {% set list = promos(template_name=current.page.template_name)%}
random 随机返回图宣顺序。 {% set randomPromos = promos(code='home_banner', random=true, limit=2)%}
fields 只返回指定字段,系统会保留 id {% set litePromos = promos(code='home_banner', fields='title,image_url,link_url', limit=3)%}

文本与时间

对已取到的数据做加工

这组工具用来处理已经拿到的数据,最常用于标题、摘要、作者、日期和说明文字。

本节写法规则:单次使用建议直接写在字段上(管道写法);需要多处复用时再先用 {% set ... %} 存变量。

工具 说明 示例
plainText 去掉 HTML 标签,保留纯文本。 {% set plainSummary = item.summary | plainText() %}
truncate 按字符数截断文本。 {% set shortTitle = item.title | truncate(28) %}
default(valueOr) 值为空时输出默认值;模板里推荐用 default(...) 作为管道写法。 {{ item.author | default('本站编辑') }}
formatDate 格式化日期或日期时间。 {{ item.published_at | formatDate('m-d', '--') }}
当前年份 直接输出当前年份,常用于页脚版权年份。 {{ 'now' | formatDate('Y') }}
timeAgo 输出相对时间。 {{ item.published_at | timeAgo('--') }}
textToHtml 把换行转成 <br> {{ item.summary | textToHtml() }}
  • 管道写法推荐:可以直接写在字段上(例如 {{ item.title | plainText() | truncate(28) }}),最直观。
  • 需要复用再用 set:当同一结果要多处使用时,先 {% set ... %} 再输出更清晰。

详情与导航辅助

详情页和公共区块常用

适合详情页上下篇、相关文章、导航区和统计区。

调用 说明 示例
nav 快速返回导航栏目列表,本质上等同于导航栏目快捷调用。每一项默认包含 idnameslugurltargetis_active 等字段。 {% set navItems = nav(limit=8)%}
navItem.is_active 导航项是否应该高亮。系统会自动处理“当前栏目”和“当前栏目的祖先栏目”两种情况,适合头部导航直接使用。 {% if item.is_active %}<a class="is-active">...</a>{% endif %}
stats 返回站点统计数据,包含 channelsarticlespagesstatus {% set siteStats = stats() %}
previous / next 取当前内容的上一篇和下一篇,通常传 articlepagecurrent.content {% set previousItem = previous(current.content) %}
related 取当前内容的相关文章,通常传 articlepagecurrent.content {% set relatedItems = related(current.content, limit=4)%}
first 从列表里取第一条。 {% set firstRelated = first(relatedItems) %}

留言板模块参数

参数和范例一起看

适合首页“最新留言”“办结反馈”“互动统计”这类区块。模板标签会自动联动留言板模块开关、是否显示姓名,以及“回复后才显示”的前台公开规则。

调用 / 参数 说明 示例
guestbookStats 返回留言板模块状态和公开统计,适合先判断模块是否可用。 {% set guestbook = guestbookStats() %}
guestbookStats.enabled 留言板是否启用。启用时返回 1,关闭或未绑定时返回 0 {% if guestbook.enabled %}...{% endif %}
guestbookStats.message 留言板关闭时的提示文案,例如“留言板模块已关闭”。 {{ guestbook.message }}
guestbookStats.total / replied / pending 公开口径下的留言统计。会自动联动“回复后才显示”。 {{ guestbook.total }}
guestbookStats.latest_created_at_label 最近一条公开留言的日期文本。 {{ guestbook.latest_created_at_label }}
guestbookMessages 返回公开留言列表。姓名会自动按留言板设置决定是否脱敏。 {% set messages = guestbookMessages(limit=6)%}
limit / offset 控制留言条数和起始位置。 {% set messages = guestbookMessages(limit=6, offset=6)%}
status 按状态筛选,可选 pendingreplied。如果站点设置为“回复后才显示”,未回复数据不会被公开返回。 {% set repliedMessages = guestbookMessages(status='replied', limit=4)%}
order 排序支持 created_at_desccreated_at_ascreplied_at_descreplied_at_asc {% set messages = guestbookMessages(order='replied_at_desc', limit=4)%}
fields 只返回指定字段,系统会保留 id {% set liteMessages = guestbookMessages(fields='display_no,name,summary,detail_url', limit=5)%}
返回字段 常用字段包括 display_nonamesummaryreply_summarystatus_labelcreated_at_labelreplied_at_labeldetail_url {{ item.reply_summary }}
{% set guestbook = guestbookStats() %} {# 先判断留言板模块是否启用:关闭时不再查询列表,直接显示关闭提示 #}

{% if guestbook.enabled %}
    {% set messages = guestbookMessages(limit=4, order='created_at_desc')%} {# 仅在启用后再查公开留言:会自动联动姓名脱敏与“回复后才显示”规则 #}
    {% for item in messages %} {# 逐条渲染首页互动反馈内容 #}
        <article class="feedback-card">
            <h3>{{ item.display_no }} {{ item.name }}</h3>
            <p>{{ item.summary }}</p>
            {% if item.reply_summary %}
                <div class="feedback-reply">{{ item.reply_summary }}</div>
            {% endif %}
            <time>{{ item.created_at_label }}</time>
            <a href="{{ item.detail_url }}">查看详情</a>
        </article>
    {% endfor %}
{% else %}
    <div class="feedback-empty">{{ guestbook.message }}</div> {# 模块关闭或未绑定时显示后端返回的统一提示 #}
{% endif %}
模板内“可提交留言”接入说明 当前模板里除了展示留言列表,还支持把页面中的表单接入留言模块提交接口。模板页和留言板模块页共用同一套后端提交判断:默认可直接提交,命中风险后才要求填写验证码。
当前实现 说明
提交接口 POST /guestbook 留言提交接口。
验证码图片 GET /guestbook/captcha 当后端要求验证码时再显示并调用。
验证码即时核验 POST /guestbook/captcha/verify 验证码显示后,输入 4 位再即时核验。
字段映射 name / phone / content / website / captcha website 为隐藏蜜罐字段;captcha 仅在后端要求时提交;单位名称电子邮箱 可继续拼接到 content
前端接入方式 data-guestbook-* + /js/guestbook-form.js 表单里按规范加好 data-guestbook-* 标记,再引入 /js/guestbook-form.js,提交、校验和提示就会自动生效。
错误提示显示规则 setFieldState(..., 'error', message) 字段错误时必须进入 error 状态(会加 is-error 并取消 hidden),否则错误文案可能被默认样式隐藏,看起来像“提交没反应”。
后端统一判断 namephonecontentwebsitecaptcha 模板页不要自己写一套独立防护规则,是否需要验证码、是否允许提交都以留言板模块后端返回为准。
验证码触发方式 风险触发 首次不强制显示验证码;命中同 IP、多次同手机号或失败记录后,后端才要求验证码。
CSRF 提交与验证码核验均要求 token 前端请求头需带 X-CSRF-TOKEN,避免被跨站伪造请求利用。
<!-- 联系页表单示例:表单结构和文案可自定义,提交逻辑统一交给公共脚本接管 -->
<form
      action="/guestbook"
      method="post"
      data-guestbook-form
      data-captcha-url="/guestbook/captcha"
      data-captcha-verify-url="/guestbook/captcha/verify"
      data-csrf-token="{{ csrfToken }}"
      novalidate>
    <input type="text" name="website" hidden tabindex="-1" autocomplete="off" data-guestbook-honeypot>

    <input type="text" name="name" placeholder="请输入你的称呼" data-guestbook-field="name">
    <div data-guestbook-error-for="name" hidden></div>

    <input type="text" name="phone" placeholder="请输入手机号" data-guestbook-field="phone">
    <div data-guestbook-error-for="phone" hidden></div>

    <!-- 此处可自行定制额外字段:填写的内容会与留言正文合并后,一并进入留言详情 -->
    <input type="text" name="company" placeholder="选填" data-guestbook-extra="company" data-guestbook-extra-label="单位名称">
    <input type="text" name="email" placeholder="选填" data-guestbook-extra="email" data-guestbook-extra-label="电子邮箱">

    <textarea name="demand" placeholder="请输入留言内容" data-guestbook-field="content" data-guestbook-content-label="您的需求"></textarea>
    <div data-guestbook-error-for="content" hidden></div>

    <!-- 验证码区块默认隐藏,由留言板模块后端要求时再显示 -->
    <div data-guestbook-captcha-block hidden>
        <input type="text" name="captcha" maxlength="4" autocomplete="off" placeholder="请输入验证码" data-guestbook-field="captcha">
        <img src="/guestbook/captcha" alt="验证码" data-guestbook-captcha-image data-guestbook-captcha-trigger>
        <div data-guestbook-error-for="captcha" hidden></div>
    </div>

    <div data-guestbook-form-error hidden></div>
    <button type="submit" data-guestbook-submit>提交留言</button>
</form>

<script src="/js/guestbook-form.js"></script>
/* 留言表单推荐 CSS(可直接复制) */

/* 留言表单基础容器:统一间距,避免字段挤在一起难读 */
[data-guestbook-form] {
    display: grid;
    gap: 12px;
}

/* 输入框基础样式:默认中性色,聚焦时仅强调边框,不改布局 */
[data-guestbook-form] input[type="text"],
[data-guestbook-form] textarea {
    width: 100%;
    min-height: 42px;
    padding: 10px 12px;
    border: 1px solid #d4d7de;
    border-radius: 10px;
    outline: none;
    font-size: 14px;
    color: #1f2937;
    background: #fff;
}

/* 聚焦态:只做轻量高亮,避免过重视觉干扰 */
[data-guestbook-form] input[type="text"]:focus,
[data-guestbook-form] textarea:focus {
    border-color: #18b981;
    box-shadow: 0 0 0 3px rgba(24, 185, 129, 0.12);
}

/* 关键规则:错误提示节点默认隐藏,只有脚本去掉 hidden 才显示 */
[data-guestbook-error-for] {
    display: none;
    font-size: 13px;
    line-height: 1.5;
}

/* 关键规则:脚本标记 is-error 后,错误文案显示为红色提示 */
[data-guestbook-error-for].is-error {
    display: block;
    color: #dc2626;
}

/* 正确态可选:如需“输入正确”反馈,用低饱和绿色避免喧宾夺主 */
[data-guestbook-error-for].is-valid {
    display: block;
    color: #059669;
}

/* 关键规则:字段错误时脚本会加 is-error,输入控件边框同步变红 */
[data-guestbook-form] input[type="text"].is-error,
[data-guestbook-form] textarea.is-error {
    border-color: #dc2626;
}

/* 字段通过时脚本会加 is-valid,输入控件边框改为绿色 */
[data-guestbook-form] input[type="text"].is-valid,
[data-guestbook-form] textarea.is-valid {
    border-color: #18b981;
}

/* 验证码区域默认由模板 hidden 控制;后端要求时脚本再显示 */
[data-guestbook-captcha-block] {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 120px;
    gap: 10px;
    align-items: center;
}

常用片段

从简单到完整直接套用

下面这些范例按“从简单到完整”的顺序整理。每段代码都可以直接复制,再把栏目别名、图宣位编码、数量和样式类名替换成自己的版本。

范例 1:最基础的文章列表 适合首页资讯区、列表入口和侧栏文章列表。
{% set articles = contentList(channel='news', limit=6)%} {# 先从 news 栏目取出 6 篇文章,后续页面内容都基于这组数据生成 #}
{% for item in articles %} {# 开始逐条处理文章:每次循环里,item 就是当前这一篇文章 #}
    <a href="{{ item.url }}">{{ item.title }}</a> {# 把文章标题输出成可点击链接,用户点击后会进入文章详情页 #}
{% endfor %} {# 循环结束:上面的链接结构会按文章数量重复渲染 #}
范例 2:带筛选和格式化的推荐文章卡片 适合首页头条卡片区,含筛选、摘要清洗、截断和日期格式化。
{% set topArticles = contentList(channel='news', is_top=true, has_image=true, order_by='published_at', order_dir='desc', limit=4)%} {# 只取“置顶且有封面图”的 4 篇文章,并按发布时间从新到旧排序 #}
{% for item in topArticles %} {# 逐条渲染推荐文章卡片,item 代表当前这篇推荐文章 #}
    {% set plainSummary = item.summary | plainText() %} {# 先把摘要里的 HTML 标签去掉,避免页面直接显示富文本代码 #}
    {% set shortSummary = plainSummary | truncate(60) %} {# 把纯文本摘要限制在 60 字内,防止卡片高度被长文本撑开 #}
    <article class="news-card"> <!-- 每篇推荐文章外层容器,便于统一排版和样式控制 -->
        <img src="{{ item.cover_image }}" alt="{{ item.title }}"> <!-- 文章封面图;alt 使用标题,方便无图或读屏场景理解内容 -->
        <h3><a href="{{ item.url }}">{{ item.title }}</a></h3> <!-- 标题点击后进入详情页,是用户最主要的阅读入口 -->
        <p>{{ shortSummary }}</p> <!-- 输出处理后的短摘要,帮助用户快速判断是否继续阅读 -->
        <time>{{ item.published_at | formatDate('Y-m-d', '--') }}</time> <!-- 输出发布时间,时间为空时用 -- 兜底,避免界面留空 -->
    </article> <!-- 当前文章卡片渲染完成 -->
{% endfor %} {# 推荐文章循环结束,页面上会得到最多 4 张推荐卡片 #}
范例 3:字段直写管道(推荐主写法) 适合“学习成本最低”的模板开发方式:直接在字段后接管道,不先 set 临时变量。
{% set list = contentList(channel='news', limit=6)%} {# 先获取列表数据,后续直接在字段上做管道处理 #}
{% for item in list %} {# 逐条渲染时,标题、摘要、时间都直接在字段上加工 #}
    <h3><a href="{{ item.url }}">{{ item.title | plainText() | truncate(28) }}</a></h3> <!-- 标题:先去标签再截断,避免长标题撑开布局 -->
    <p>{{ item.summary | plainText() | default(item.content_html | plainText()) | truncate(70) }}</p> <!-- 摘要为空时回退正文纯文本,再统一截断 -->
    <time>{{ item.published_at | formatDate('Y-m-d', '--') }}</time> <!-- 时间为空时用 -- 兜底,保证界面稳定 -->
{% endfor %} {# 推荐:只在需要复用同一结果时再用 set 存变量 #}
范例 4:固定推荐和普通列表拆开展示 第一组用了 include_ids 固定指定文章,并通过 fields 只返回需要字段;第二组用了 exclude_ids 把这些文章排除掉,再补一组普通列表。适合首页“重点推荐 + 更多资讯”的双区块布局。
{% set focusIds = '12,15,18' %} {# 先定义要“固定展示”的文章 ID,确保运营指定的内容一定出现 #}
{% set focusArticles = contentList(include_ids=focusIds, fields='title,url,published_at', order_by='id', order_dir='asc')%} {# 第一组数据:只读取这些指定 ID 的文章,并只返回当前区块需要的字段 #}
{% set moreArticles = contentList(channel='news', exclude_ids=focusIds, limit=6)%} {# 第二组数据:从普通栏目里补充内容,同时排除已在固定区出现的文章 #}
{% for item in focusArticles %} {# 循环输出固定推荐区,保证重点内容优先曝光 #}
    <a class="focus-link" href="{{ item.url }}">{{ item.title }}</a> <!-- 固定推荐文章链接,通常用于头部或重点区域 -->
{% endfor %} {# 固定推荐区输出完成 #}
{% for item in moreArticles %} {# 循环输出普通补充列表,让页面内容更完整 #}
    <a class="more-link" href="{{ item.url }}">{{ item.title }}</a> <!-- 普通文章链接,作为推荐区之外的延伸阅读 -->
{% endfor %} {# 普通补充列表输出完成 #}
范例 5:单页列表和统一链接输出 这段调用用了 type='page'status='published'order='updated_at_desc'limit=6,用来取最近更新的单页。输出链接时再配合 linkTo(type='page', id=item.id, default='#'),统一生成单页地址,适合“关于我们 / 联系方式 / 招生说明”这类单页入口区。
{% set pageItems = contentList(type='page', status='published', order='updated_at_desc', limit=6)%} {# 读取已发布的单页面内容,并按最近更新时间排序,方便展示“最新更新” #}
{% for item in pageItems %} {# 逐条处理单页面数据,item 就是当前这一个单页 #}
    <a href="{{ linkTo(type='page', id=item.id, default='#')}}">{{ item.title }}</a> <!-- 用 linkTo 统一生成单页地址,避免手写链接造成跳转错误 -->
{% endfor %} {# 单页列表循环结束,以上链接会根据单页数量重复输出 #}
模板绑定说明:文章和单页面都支持内容级覆盖 后台现在支持两层模板绑定。栏目上的模板是默认模板,用来决定这一类内容通常走哪一个详情模板;内容编辑页里的模板选择是单篇覆盖,用来让某一篇文章或某一个单页面单独改用别的模板。最终前台渲染时,会优先使用内容自己绑定的模板,没有时再回退到栏目模板。
范例 6:基础轮播图调用 用了 promoscode='home_banner'limit=5。作用是读取图宣位编码为 home_banner 的前 5 张图,并输出图片、跳转地址和打开方式。适合首页轮播、横幅区和头部视觉位。
{% set banners = promos(code='home_banner', limit=5)%} {# 从图宣位 home_banner 读取最多 5 条素材,通常用于首页轮播 #}
{% for item in banners %} {# 逐条渲染轮播项,item 代表当前这张轮播图的数据 #}
    <a href="{{ item.link_url }}" target="{{ item.link_target }}"> <!-- 轮播图外层可点击区域:点击后按配置跳转到目标地址 -->
        <img src="{{ item.image_url }}" alt="{{ item.title }}"> <!-- 轮播图片本体:image_url 是图片地址,title 作为无障碍说明文字 -->
    </a> <!-- 当前轮播项链接闭合,防止后续元素被错误包裹 -->
{% endfor %} {# 轮播项循环结束,页面上会得到最多 5 个可轮播素材 #}
范例 7:单条图宣和模板范围图宣 第一段直接用 promo(code='home_banner') 取一条图宣,适合页面主视觉。第二段用了 page_scopetemplate_namerandomlimit,会根据当前页面模板取一组更贴近当前场景的图宣,适合模板专属广告位或活动位。
{% set banner = promo(code='home_banner') %} {# 读取单条主视觉图宣,适合页面顶部“只展示 1 条”的场景 #}
{% if banner %} {# 只有拿到有效数据才输出,避免空数据时页面出现空白图片或坏链接 #}
    <a href="{{ banner.link_url }}" target="{{ banner.link_target }}"> <!-- 单条图宣点击区域:用于跳转活动页、专题页或外部地址 -->
        <img src="{{ banner.image_url }}" alt="{{ banner.title }}"> <!-- 单条图宣图片:显示视觉素材,alt 提供辅助说明 -->
    </a> <!-- 单条图宣渲染结束 -->
{% endif %} {# 条件判断结束:无数据时整段不会输出任何 HTML #}
{% set scopedPromos = promos(page_scope='home', template_name=current.page.template_name, random=true, limit=2)%} {# 再按“页面范围 + 当前模板名”取 2 条更贴合当前页面的图宣素材 #}
{% for item in scopedPromos %} {# 逐条渲染模板范围内的图宣,作为补充推荐位 #}
    <a href="{{ item.link_url }}" target="{{ item.link_target }}">{{ item.title }}</a> <!-- 文本形式的图宣入口,适合侧栏或轻量推荐区 -->
{% endfor %} {# 模板范围图宣循环结束 #}
范例 8:详情页上一篇、下一篇和相关文章 用了 previous(current.content)next(current.content)related(current.content, limit=4)。它会基于当前详情页内容,自动取出上一篇、下一篇和 4 条相关文章,适合文章详情页底部的延伸阅读区。
{% set previousItem = previous(current.content) %} {# 基于当前正在阅读的内容,获取时间或排序上更早的一篇文章 #}
{% set nextItem = next(current.content) %} {# 基于当前内容,获取下一篇文章,方便用户连续阅读 #}
{% set relatedItems = related(current.content, limit=4)%} {# 获取与当前内容相关的 4 篇文章,用于“你可能还想看”区域 #}
{% if previousItem %} {# 只有确实存在上一篇时才显示链接,避免出现无效入口 #}
    <a href="{{ previousItem.url }}">上一篇:{{ previousItem.title }}</a> <!-- 上一篇文章跳转链接,帮助用户回看前一条内容 -->
{% endif %} {# 上一篇条件块结束 #}
{% if nextItem %} {# 只有存在下一篇时才显示链接,保证交互始终有效 #}
    <a href="{{ nextItem.url }}">下一篇:{{ nextItem.title }}</a> <!-- 下一篇文章跳转链接,引导用户继续阅读 -->
{% endif %} {# 下一篇条件块结束 #}
{% for item in relatedItems %} {# 遍历相关文章列表,把每篇相关内容都渲染成入口链接 #}
    <a href="{{ item.url }}">{{ item.title }}</a> <!-- 单条相关文章链接,点击后进入对应文章详情页 -->
{% endfor %} {# 相关文章循环结束 #}
范例 9:从列表里取第一条做头图 这段调用用了 with_cover=true 先筛出带封面的文章,再用 first 从结果里取第一条,适合首页头图、焦点新闻大卡片这类“首条单独展示”的结构。
{% set coverArticles = contentList(channel='news', with_cover=true, limit=5)%} {# 先取 5 篇带封面图的文章,确保后续头图位一定有图可显示 #}
{% set firstArticle = first(coverArticles) %} {# 从结果里拿第 1 篇,作为页面最醒目的主头图内容 #}
{% if firstArticle %} {# 只有拿到首条文章时才渲染头图区,避免无数据时报错或空框 #}
    <article class="hero-article"> <!-- 头图卡片外层容器,用于控制整块大图+标题的布局 -->
        <img src="{{ firstArticle.cover_image }}" alt="{{ firstArticle.title }}"> <!-- 头图图片本体,来源于首篇文章封面图字段 -->
        <h3><a href="{{ firstArticle.url }}">{{ firstArticle.title }}</a></h3> <!-- 头图标题和链接,点击可进入该文章详情页 -->
    </article> <!-- 头图卡片渲染完成 -->
{% endif %} {# 头图条件块结束 #}
范例 10:导航栏和面包屑 第一段推荐直接用 nav(limit=8),每个导航项会附带 is_activetarget,适合头部导航高亮。第二段用了 breadcrumb(channel_id=current.channel.id) 生成当前栏目路径。适合页面头部导航和栏目页面包屑。
{% set navItems = nav(limit=8)%} {# 读取最多 8 个头部导航项,作为页面主导航菜单的数据来源 #}
{% for item in navItems %} {# 逐条输出导航项,item 里包含名称、地址、是否高亮、打开方式等信息 #}
    <a{% if item.is_active %} class="is-active"{% endif %} href="{{ item.url }}"{% if item.target == '_blank' %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.name }}</a> <!-- 导航链接:当前页自动加高亮;若配置新窗口则附带安全属性后再打开 -->
{% endfor %} {# 主导航循环结束 #}
{% set breadcrumbs = breadcrumb(channel_id=current.channel.id)%} {# 按当前栏目生成面包屑路径,用于告诉用户“当前位置” #}
{% for item in breadcrumbs %} {# 遍历面包屑路径节点,通常从首页到当前栏目逐级展示 #}
    <a href="{{ item.url }}">{{ item.name }}</a> <!-- 单个面包屑节点链接,支持快速返回上级页面 -->
{% endfor %} {# 面包屑循环结束 #}
范例 11:列表页搜索 + 分页(服务端) 关键词走 current.page.keyword,查询交给 contentPage,分页用 pagination.pages 输出。适合“全量搜索并分页”的标准列表页。
{% set pageData = contentPage(type='article', per_page=5, page_name='page', window=2, keyword=current.page.keyword)%} {# 在服务端执行“搜索 + 分页”:每页 5 条,并按 page 参数切换页码 #}
{% set list = pageData.items %} {# 从返回结果中取出“当前页文章列表”部分,供下面循环渲染 #}
{% set pager = pageData.pagination %} {# 从返回结果中取出分页信息(总页数、当前页、每个页码链接等) #}
{% for item in list %} {# 逐条输出当前页文章,item 就是当前这一篇 #}
    <a href="{{ item.url }}">{{ item.title }}</a> <!-- 文章标题入口,用户点击后进入对应详情页 -->
{% endfor %} {# 当前页文章列表渲染结束 #}
{% for p in pager.pages %} {# 循环输出每个页码按钮,支持用户在结果页之间切换 #}
    <a href="{{ p.url }}"{% if p.is_current %} class="active"{% endif %}>{{ p.label }}</a> <!-- 单个分页按钮:当前页会附加 active 样式用于高亮提示 -->
{% endfor %} {# 分页按钮渲染结束 #}
范例 12:统一配置项控制分页参数 config() 读取模板配置项,统一控制每页条数和分页窗口,方便后期全站统一调整。
{% set perPage = config('theme.list.per_page', 5) %} {# 从统一配置读取每页条数;未配置时默认 5 #}
{% set pagerWindow = config('theme.list.window', 2) %} {# 从统一配置读取分页窗口;未配置时默认 2 #}
{% set pageData = contentPage(type='article', per_page=perPage, page_name='page', window=pagerWindow, keyword=current.page.keyword) %} {# 把配置项直接用于服务端分页查询 #}
{% for item in pageData.items %} {# 正常渲染当前页内容 #}
    <a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
范例 13:统一配置项(静态文案 + 开关) 除了分页参数,config() 也适合管理模板静态项,例如区块标题、副标题和开关状态。
{% set sectionTitle = config('theme.home.hot_title', '精华内容') %} {# 统一读取区块标题:后台可改,模板不用改代码 #}
{% set sectionIntro = config('theme.home.hot_intro', '精选高价值内容') %} {# 统一读取副标题:未配置时使用默认文案 #}
{% set showHot = config('theme.home.hot_enabled', true) %} {# 统一读取显示开关:便于按站点快速启停模块 #}
{% if showHot %} {# 只有开关为 true 时才渲染这整个区块 #}
    <section class="hot-block">
        <h2>{{ sectionTitle }}</h2>
        <p>{{ sectionIntro }}</p>
    </section>
{% endif %}
范例 14:query 链式写法(组合函数) 当你更偏好“从左到右”的可读性时,推荐用 query 链式写法,逻辑更直观,适合复杂筛选。
{% set pageData = query('article') | channel('news') | top(true) | keyword(current.page.keyword) | perPage(5) | pageName('page') | window(2) | paginate() %} {# 链式组合:栏目 + 置顶 + 关键词 + 分页参数,一行表达完整查询意图 #}
{% for item in pageData.items %} {# 渲染当前页文章列表 #}
    <a href="{{ item.url }}">{{ item.title | plainText() | truncate(28) }}</a>
{% endfor %}
{% for p in pageData.pagination.pages %} {# 渲染页码导航 #}
    <a href="{{ p.url }}"{% if p.is_current %} class="active"{% endif %}>{{ p.label }}</a>
{% endfor %}
范例 15:栏目页“全部内容”入口 栏目页加 all=1 后,列表不再限制当前栏目;可用 current.page.show_all 做“全部内容”按钮高亮。
<a href="/cat/{{ current.channel.slug }}?site={{ site.site_key }}&all=1" class="sidebar-link{% if current.page.show_all %} active{% endif %}">全部内容</a> <!-- “全部内容”入口:点击后带上 all=1 参数,列表范围从当前栏目切换为全站文章;当前状态自动高亮 -->
{% set pageData = contentPage(type='article', per_page=5, page_name='page', window=2, keyword=current.page.keyword)%} {# 当 URL 包含 all=1 时,后端会按“全站文章”返回分页结果,而不是只返回当前栏目 #}
范例 16:导航按类型拆分 通过 type='list' / type='page' 把导航拆成“栏目导航”和“单页导航”,减少模板内复杂判断。
{% set navListChannels = channels(is_nav=true, type='list', status=1, limit=8)%} {# 先取“栏目型”导航节点:这类节点通常对应新闻、公告、通知等栏目页 #}
{% set navPageChannels = channels(is_nav=true, type='page', status=1, limit=8)%} {# 再取“单页型”导航节点:这类节点通常对应关于我们、联系我们等单页 #}
{% for item in navListChannels %} {# 先渲染栏目导航区,便于把资讯类入口集中展示 #}
    <a href="{{ item.url }}">{{ item.name }}</a> <!-- 栏目导航链接,点击后进入对应栏目列表页 -->
{% endfor %} {# 栏目导航输出完成 #}
{% for item in navPageChannels %} {# 再渲染单页导航区,便于把说明类页面单独展示 #}
    <a href="{{ item.url }}">{{ item.name }}</a> <!-- 单页导航链接,点击后进入对应单页内容 -->
{% endfor %} {# 单页导航输出完成 #}
新开发人员常见坑位(建议先看)
  • 搜索范围错误:搜索必须走 current.page.keyword + 服务端分页,不要只在当前页前端筛选。
  • 摘要为空:摘要为空时要回退 item.content_html | plainText(),再做 truncate(),避免出现空描述。
  • 职责混用:模板只做展示和字段组合,权限判断、业务逻辑、复杂条件放后台处理。
  • 导航高亮遗漏:涉及“全部内容”时要配合 current.page.show_all 处理高亮状态。
  • 链接安全细节:target="_blank" 的外链请同时加 rel="noopener noreferrer"
  • 列表稳定性:时间、摘要、图片等字段建议做默认值或条件判断,避免空字段导致页面断层。
  • 留言错误提示缺失:表单脚本必须给错误字段设置 is-error 并显示对应 data-guestbook-error-for 节点,否则会出现“提交无反应”的错觉。