基础语法与限制
先看这一节模板只负责展示,不写 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/目录。
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_items、tag_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 | 当前页面类型,常见值有 home、channel、article、page。 |
{% 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 | 支持首页、栏目、文章、单页四种链接类型,也可配合 id、channel_id、slug、default 使用。 |
{{ 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,它会返回 items 和 pagination 两段数据。
| 调用 / 参数 | 说明 | 示例 |
|---|---|---|
| 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.items 和 result.pagination。page_name 默认 page,window 控制分页条左右页码数量。 |
{% 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_desc、updated_at_desc、id_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 | 快速返回导航栏目列表,本质上等同于导航栏目快捷调用。每一项默认包含 id、name、slug、url、target、is_active 等字段。 |
{% set navItems = nav(limit=8)%} |
| navItem.is_active | 导航项是否应该高亮。系统会自动处理“当前栏目”和“当前栏目的祖先栏目”两种情况,适合头部导航直接使用。 | {% if item.is_active %}<a class="is-active">...</a>{% endif %} |
| stats | 返回站点统计数据,包含 channels、articles、pages、status。 |
{% set siteStats = stats() %} |
| previous / next | 取当前内容的上一篇和下一篇,通常传 article、page 或 current.content。 |
{% set previousItem = previous(current.content) %} |
| related | 取当前内容的相关文章,通常传 article、page 或 current.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 | 按状态筛选,可选 pending 或 replied。如果站点设置为“回复后才显示”,未回复数据不会被公开返回。 |
{% set repliedMessages = guestbookMessages(status='replied', limit=4)%} |
| order | 排序支持 created_at_desc、created_at_asc、replied_at_desc、replied_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_no、name、summary、reply_summary、status_label、created_at_label、replied_at_label、detail_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),否则错误文案可能被默认样式隐藏,看起来像“提交没反应”。 |
| 后端统一判断 | name、phone、content、website、captcha |
模板页不要自己写一套独立防护规则,是否需要验证码、是否允许提交都以留言板模块后端返回为准。 |
| 验证码触发方式 | 风险触发 | 首次不强制显示验证码;命中同 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;
}
常用片段
从简单到完整直接套用下面这些范例按“从简单到完整”的顺序整理。每段代码都可以直接复制,再把栏目别名、图宣位编码、数量和样式类名替换成自己的版本。
{% set articles = contentList(channel='news', limit=6)%} {# 先从 news 栏目取出 6 篇文章,后续页面内容都基于这组数据生成 #}
{% for item in articles %} {# 开始逐条处理文章:每次循环里,item 就是当前这一篇文章 #}
<a href="{{ item.url }}">{{ item.title }}</a> {# 把文章标题输出成可点击链接,用户点击后会进入文章详情页 #}
{% endfor %} {# 循环结束:上面的链接结构会按文章数量重复渲染 #}
{% 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 张推荐卡片 #}
{% 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 存变量 #}
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 %} {# 普通补充列表输出完成 #}
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 %} {# 单页列表循环结束,以上链接会根据单页数量重复输出 #}
promos、code='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 个可轮播素材 #}
promo(code='home_banner') 取一条图宣,适合页面主视觉。第二段用了 page_scope、template_name、random 和 limit,会根据当前页面模板取一组更贴近当前场景的图宣,适合模板专属广告位或活动位。
{% 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 %} {# 模板范围图宣循环结束 #}
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 %} {# 相关文章循环结束 #}
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 %} {# 头图条件块结束 #}
nav(limit=8),每个导航项会附带 is_active 和 target,适合头部导航高亮。第二段用了 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 %} {# 面包屑循环结束 #}
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 %} {# 分页按钮渲染结束 #}
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 %}
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 %}
{% 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 %}
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 时,后端会按“全站文章”返回分页结果,而不是只返回当前栏目 #}
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节点,否则会出现“提交无反应”的错觉。