feat(theme): 新增三套高校官网风皮肤 poly-blue/agri-forest/jade-normal
build / build-push (push) Successful in 51s

以 default 为基座(文件最全:含事项库 service/guide 两模板),设计语言与配色取自
真实高校官网 CSS(浙理工 #0558A5+橙、信阳农林 #1C6E19+麦穗金、信阳师大 #008D3F+暖橙)。
三包均自带九段 home-blocks、:root 契约变量别名、中文防塌列三件套、真实实拍截图
(1920 无头截图缩样)。

- UniversitySkinContractTest(7 用例):九段 fragment/homeStyle 分发/防塌列/变量别名/
  层序/自有截图/主色色域(蓝或绿通道主导,粉紫禁入)
- ThemeRegistryTest +哨兵:三包注册成功且主色与 default/scholar-blue 及彼此互异
- jade-normal 修复:news_grid 全宽封面变体把正文列压成 0 宽致 390px 溢出 47px,
  增 .jn-feat-a--stack 纵向变体,复测 overflowX=0
- 门禁:ThemeContractIT 14 主题×7 路由全绿;全量 mvn test 393/393 绿
This commit is contained in:
chenxing
2026-09-20 02:31:53 +08:00
parent d2df12b91e
commit 82d6ff4602
50 changed files with 9289 additions and 0 deletions
@@ -0,0 +1,58 @@
# agri-forest · 农林绿
云海 CMS 皮肤包。fork 自 `default`(数据契约、模板 th:* 绑定与 default 完全一致),视觉层按农林院校门户语言全部重写,设计语言参考信阳农林学院官网(仅学语言,不复制实现)。
## 风格定位
- **气质词**:庄重、质朴、农林学府。
- **配色**:墨绿 `#1C6E19` 主导(通栏导航/日期方块/页脚主底),加深端 `#14510F`(页脚/标题条),提亮端 `#3FA43A`(当前导航项/hover),麦穗金 `#C8A45C` 只做装饰线与角标(栏目标题下划线、导航当前项下杠、日期方块 hover 金线、置顶角标)。
- **结构**:白色页头(左校徽+校名,右工具行:今日日期 + 校园直通车下拉 + 站内搜索)→ 墨绿通栏主导航(白字,当前栏目提亮绿底+金下杠,导航下方与 banner 无缝)→ 通栏轮播(左下墨绿标题条带金边)→ 首页区块 → 墨绿多列页脚(更深底条)。
- **工艺**:小圆角 4px;间距刻度 4/8/12/16/24/32/48;字号阶梯 12/13/14/15/16/18/20/24/28/32;hover 过渡 0.2s ease;正文行高 1.75;无渐变堆砌、无粉紫、无玻璃拟态;图标一律内联 SVG。
## 参数取值建议(theme.json,13 项)
| key | cssVar | 默认 | 建议 |
| --- | --- | --- | --- |
| primary | `--af-primary` | `#1C6E19` | 主色。换季校庆可微调为 `#1F7A1B`,勿低于 `#14510F` 明度(导航白字对比) |
| primaryDark | `--af-primary-dark` | `#14510F` | 页脚主底/轮播标题条。与 primary 保持同色相 1~2 档差 |
| primaryLight | `--af-primary-light` | `#3FA43A` | 当前导航项/hover。需要更醒目可用 `#4DB548`,过亮会刺眼 |
| accent | `--af-accent` | `#C8A45C` | 麦穗金装饰线/角标。庄重场合可降饱和为 `#B8934A` |
| navBg | `--af-nav-bg` | `#1C6E19` | 主导航底色。默认与 primary 一致;要"导航深一档"配 `#14510F` |
| footerBg | `--af-footer-bg` | `#14510F` | 页脚主底;底条固定更深一档(`--af-primary-deep`,非参数) |
| text / textLight | `--af-text` / `--af-text-light` | `#2A3327` / `#6C7A67` | 正文/次要文字,偏绿灰 |
| bg | `--af-bg` | `#F5F8F3` | 页面底色,带一点绿调的暖白 |
| cardBg | `--af-card-bg` | `#FFFFFF` | 卡片底,建议保持纯白 |
| border | `--af-border` | `#DDE8DA` | 边框/分隔线,绿灰调 |
| linkGreen | `--af-link` | `#277428` | 正文链接绿,介于主色深浅之间 |
| listLayout | —(select) | `date` | 列表页形态:`date`=46px 日期方块行(农林官网典型);`plain`=通栏行式(日期右置),信息密度更高 |
微调优先级:站点 `theme_params`(本表参数)→ 站点 `custom_css` → 本包 main.css(契约 §6)。
## 首页九段形态表(fragments/home-blocks.html)
站点配 `home_layout` 时首页正文由本包 fragment 渲染(换肤必须连正文一起换):
| fragment | 触发 | 形态 |
| --- | --- | --- |
| `news_grid` | article_list + homeStyle=news_grid | 三图焦点(横排 3 封面卡)+ 双列要闻行式列表;支持站点背景带(news_bg_image,画在卡片之下) |
| `media_focus` | article_list + homeStyle=media_focus | 学术动态:双列密行 + 墨绿来源徽标 |
| `compact` | article_list + homeStyle=compact | 通知公告:46px 墨绿日期方块(日大号+年月小号),行 hover 出金线 |
| `image_wall` | article_list + homeStyle=image_wall | 四列图片墙(仅有封面条目),悬浮标题 |
| `card` | article_list 其他/未设 | 默认卡:头条图文(有封面则图文)+ 行式列表 |
| `ticker` | type=ticker | 要闻速递横条:墨绿底、金色分隔、单行不塌列 |
| `gallery` | type=gallery | 校园映像:居中标题+副题+四列图墙+悬浮标题 |
| `links` | type=links | 专题网站:4 列缩略卡(绿标题条+金下杠+圆徽),分组拉平 |
| `empty` | 各形态 items 为空 | 空态「栏目暂无内容」(守卫包在外层 th:block,th:replace 优先级 100 先于 th:if 300) |
未知类型回落 `themes/common` 通用骨架;`:root` 已把 `--af-*` 别名到契约变量(`--primary/--text/--card-bg/--border/--bg/--radius`),兜底骨架与 themeVarsCss 换色可穿透。
## 纪律要点
- URL 一律 `url.*` 生成,无硬编码绝对地址;非转义输出仅契约七处白名单。
- 中文防塌列:`.af-ticker-list{min-width:0;white-space:nowrap}`、`.af-ticker-item{flex:none;text-overflow:ellipsis}`、`.af-block-item-title{min-width:0;overflow:hidden;text-overflow:ellipsis}`。
- 网格一律 `minmax(0,1fr)`;≤768px 单列、导航横滚、子菜单静态平铺。
- 背景带层序:`.af-sec-bg` z-index:0,其后 `.af-block` z-index:1。
## 截图说明
`assets/screenshot.png` 为按本皮肤设计语言绘制的门户结构示意图(非浏览器实拍)。管理端选择器如需实拍图,起服后截首页替换即可(记入遗留项)。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,87 @@
/* agri-forest 皮肤包脚本:页头日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘),无任何外部依赖 */
(function () {
/* 页头工具行日期(本地时间,YYYY年M月D日 星期X) */
var dateEl = document.getElementById('siteDate');
if (dateEl) {
var now = new Date();
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
+ now.getDate() + '日 星期' + week;
}
/* 轮播 */
var root = document.querySelector('.af-banner');
var slides = document.querySelectorAll('.af-banner-slide');
var dots = document.querySelectorAll('.af-banner-dots button');
var prevBtn = document.querySelector('.af-banner-arrow.prev');
var nextBtn = document.querySelector('.af-banner-arrow.next');
if (!slides.length || !root) {
return;
}
var current = 0;
var timer = null;
var INTERVAL = 5000;
function show(index) {
slides[current].classList.remove('active');
if (dots[current]) {
dots[current].classList.remove('active');
}
current = (index + slides.length) % slides.length;
slides[current].classList.add('active');
if (dots[current]) {
dots[current].classList.add('active');
}
}
function start() {
stop();
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, INTERVAL);
}
}
function stop() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
show(index);
start();
});
});
if (prevBtn) {
prevBtn.addEventListener('click', function () {
show(current - 1);
start();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', function () {
show(current + 1);
start();
});
}
root.addEventListener('mouseenter', stop);
root.addEventListener('mouseleave', start);
root.addEventListener('focusin', stop);
root.addEventListener('focusout', start);
root.setAttribute('tabindex', '-1');
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') {
show(current - 1);
start();
} else if (e.key === 'ArrowRight') {
show(current + 1);
start();
}
});
start();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="af-page af-container" id="main-content">
<div class="af-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="af-side-nav-list">
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="af-page-card">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a class="af-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="af-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="af-crumb-current">正文</span>
</nav>
<article class="af-article">
<h1 class="af-article-title" th:text="${article.title}">文章标题</h1>
<div class="af-article-meta">
<time th:if="${article.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
<span th:if="${!#strings.isEmpty(article.authorName)}"
th:text="'作者:' + ${article.authorName}">作者</span>
<span th:if="${!#strings.isEmpty(article.source)}"
th:text="'来源:' + ${article.source}">来源</span>
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
</div>
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
其余字段一律 th:text 转义输出 -->
<div class="af-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="af-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}">
<span class="sib-label">上一篇</span>
<span class="sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="sib-none prev" th:if="${prevArticle == null}">
<span class="sib-label">上一篇</span>
<span class="sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}">
<span class="sib-label">下一篇</span>
<span class="sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="sib-none next" th:if="${nextArticle == null}">
<span class="sib-label">下一篇</span>
<span class="sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚(农林绿·墨绿多列):品牌区(校徽+站名+社媒图标) + 地址/联系/版权/备案 + 二维码位
+ 友链分组(浅色带)+ 更深底条(校名)。
数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName),
增强键(docs/12):footerAddressLines / qr_wechat / qr_weibo / social_*(有值才渲染,空站点优雅降级)。 -->
<footer class="af-footer" th:fragment="footer">
<!-- 主区:墨绿多列 -->
<div class="af-footer-main">
<div class="af-container af-footer-cols">
<!-- 品牌列 -->
<div class="af-footer-brand">
<div class="af-footer-logo-row">
<img th:if="${site.config['logo'] != null}" class="af-footer-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<span class="af-footer-site" th:text="${site.name}">站点名称</span>
</div>
<!-- 社媒主页(url 校验,有值才渲染对应图标;图标内联 SVG,契约 §4.6 不引外链) -->
<div class="af-social" th:if="${site.config['social_wechat'] != null
or site.config['social_weibo'] != null
or site.config['social_douyin'] != null
or site.config['social_bilibili'] != null}">
<a th:if="${site.config['social_wechat'] != null}" class="af-social-icon"
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/></svg>
</a>
<a th:if="${site.config['social_weibo'] != null}" class="af-social-icon"
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/></svg>
</a>
<a th:if="${site.config['social_douyin'] != null}" class="af-social-icon"
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/></svg>
</a>
<a th:if="${site.config['social_bilibili'] != null}" class="af-social-icon"
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M7.2 4.3 9 6.4h6l1.8-2.1c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/></svg>
</a>
</div>
</div>
<!-- 地址/联系列(多校区地址按行直出;contact/copyright/beian 可空,空块收敛) -->
<div class="af-footer-info">
<ul class="af-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
</ul>
<p class="af-footer-line" th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p class="af-footer-line" th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p class="af-footer-line" th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
<!-- 二维码位(asset 校验,有值才渲染) -->
<div class="af-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
<figure class="af-qr" th:if="${site.config['qr_wechat'] != null}">
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
<figcaption>官方微信</figcaption>
</figure>
<figure class="af-qr" th:if="${site.config['qr_weibo'] != null}">
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
<figcaption>官方微博</figcaption>
</figure>
</div>
</div>
</div>
<!-- 友链区(浅绿带;无分组的站整段不渲染;分组无链接时防御性跳过——数据上分组由链接行物化,不可达) -->
<div class="af-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="af-container af-footer-links-inner">
<div class="af-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="af-group-name" th:text="${group.groupName}">分组</span>
<p class="af-links-row">
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
<!-- B4 友链 Logo:有 logo 图片+名称并排(渲染期补基准),无 logo 纯文字 -->
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="af-link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</p>
</div>
</div>
</div>
<!-- 底条(更深墨绿):全站无任何配置信息时仍保留结构完整(空块由 CSS 收敛) -->
<div class="af-footer-bottom">
<div class="af-container af-footer-bottom-inner">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</div>
</footer>
</html>
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/8.6),与 default 数据契约一致 -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
</html>
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部(农林绿):白色页头(左校徽+校名 / 右工具行:日期+校园直通车下拉+搜索)
+ 墨绿通栏主导航(白字,当前栏目 primaryLight 底),导航下方与 banner 无缝衔接。
数据契约与 default 完全一致:topbar 日期由 main.js 填充、专业院系下拉(site.colleges 空则不渲染)。 -->
<header class="af-header" th:fragment="header">
<a class="af-skip-link" href="#main-content">跳到主要内容</a>
<!-- 白色页头 -->
<div class="af-head">
<div class="af-head-inner">
<!-- 左:校徽 + 校名 -->
<div class="af-brand">
<img th:if="${site.config['logo'] != null}" class="af-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="af-sitename" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<!-- 右:工具行(今日日期 + 校园直通车 + 站内搜索);无子站站点下拉整块不渲染 -->
<div class="af-head-side">
<span class="af-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="af-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '校园直通车'}">
<span class="af-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">校园直通车</a>
<i class="af-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="af-colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
<form class="af-search" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
</div>
</div>
<!-- 墨绿通栏主导航:外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<nav class="af-nav" aria-label="栏目导航">
<ul>
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="af-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:白底面板(.af-sub-nav 样式见 assets/css/main.css);当前项高亮(docs/11) -->
<ul class="af-sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}"
th:classappend="${sub.id == currentCategoryId} ? 'active'">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!--
agri-forest(农林绿)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
九段形态:news_grid(三图焦点+要闻列表)/ media_focus(来源徽标密行)/ compact(46px 日期方块行)/
image_wall(四列图片墙)/ card(默认卡:头条+行式)/ ticker(速递横条)/ gallery(校园映像图廊)/
links(专题网站缩略卡)/ empty(空态)。
契约:block 为渲染端装配的同构 Map(type/title/category.name/homeStyle/items/moreUrl/subtitle/
background/source/linkGroups),与回落模式 categoryBlocks 兼容;区块标题一律取 category.name。
空态守卫必须包在 th:block 外层:th:replace(优先级 100)先于 th:if(300)求值,写在同一标签上等于没写。
-->
<!-- 形态一:三图焦点 + 要闻列表(homeStyle=news_grid)——横排 3 张封面卡 + 双列行式列表 -->
<section class="af-section af-section--news" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="af-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="af-block af-container">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M4 5h11a1 1 0 0 1 1 1v13H6a2 2 0 0 1-2-2V5z"/><path d="M16 9h3a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2"/><path d="M7 9h6M7 13h6M7 17h4"/></svg>
</span>
<h2 th:text="${block.category.name}">学校要闻</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<div class="af-news-body" th:if="${!#lists.isEmpty(block.items)}"
th:with="rest=${#lists.size(block.items) > 3 ? block.items.subList(3, block.items.size()) : null}">
<ul class="af-trio">
<li class="af-trio-item" th:each="item, stat : ${block.items}" th:if="${stat.index &lt; 3}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<span class="af-trio-cover" th:if="${item.coverImage != null}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
</span>
<span class="af-trio-cover af-trio-ph" th:if="${item.coverImage == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M5 19C5 9 12 4 20 4c0 9-5 15-12 15-1.5 0-3-.4-3-1z"/><path d="M5 19c3-5 6-8 10-10"/></svg>
</span>
<span class="af-trio-cap">
<span class="af-trio-title" th:text="${item.title}">焦点标题</span>
<span class="af-trio-meta">
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<ul class="af-block-list af-block-list--two" th:if="${rest != null}">
<li class="af-line" th:each="item : ${rest}">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:来源徽标密行(homeStyle=media_focus)——学术动态,双列紧凑行 + 来源徽标 -->
<section class="af-section af-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M9 3h6M10 3v6L4.6 18A2 2 0 0 0 6.3 21h11.4a2 2 0 0 0 1.7-3L14 9V3"/><path d="M7.5 15h9"/></svg>
</span>
<h2 th:text="${block.category.name}">学术动态</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-block-list af-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="af-line af-line--media" th:each="item : ${block.items}">
<span class="af-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">来源</span>
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:46px 日期方块列表(homeStyle=compact)——通知公告:日大号 + 年月小号,墨绿底白字,hover 金线 -->
<section class="af-section af-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 10v4l6 1 9 5V4L9 9l-6 1z"/><path d="M7 15l1.5 5h3L10 16"/></svg>
</span>
<h2 th:text="${block.category.name}">通知公告</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-block-list af-notice-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="af-line af-line--date" th:each="item : ${block.items}">
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:四列图片墙(homeStyle=image_wall)——仅取有封面的条目,悬浮标题 -->
<section class="af-section af-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
</span>
<h2 th:text="${block.category.name}">图片新闻</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="af-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="af-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 形态五:默认卡(homeStyle 未设/未知)——整宽头条(有封面则图文)+ 行式列表 -->
<section class="af-section af-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M6 3h8l4 4v14H6V3z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>
</span>
<h2 th:text="${block.category.name}">栏目名</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<div class="af-feat" th:if="${!#lists.isEmpty(block.items)}" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="af-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="af-feat-body">
<span class="af-feat-title" th:text="${feat.title}">头条标题</span>
<span class="af-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="af-feat-meta">
<span class="af-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</div>
<ul class="af-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="af-line" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="af-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="af-container af-ticker-inner">
<span class="af-ticker-label">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 10v4l6 1 9 5V4L9 9l-6 1z"/><path d="M7 15l1.5 5h3L10 16"/></svg>
<span th:text="${block.title}">要闻速递</span>
</span>
<ul class="af-ticker-list">
<li th:each="item : ${block.items}">
<a class="af-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:校园映像图廊(block.items = 轮播位 gallery,四列图片墙 + 悬浮标题) -->
<section class="af-section af-container af-gallery" th:fragment="gallery(block)" aria-label="校园映像"
th:if="${!#lists.isEmpty(block.items)}">
<div class="af-gallery-head">
<div class="af-block-title af-gallery-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
</span>
<h2 th:text="${block.title}">校园映像</h2>
</div>
<p class="af-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
Campus Gallery
</p>
</div>
<ul class="af-wall af-wall--4">
<li class="af-wall-item" th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
<span class="af-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:专题网站缩略卡(block.linkGroups 由渲染端按 group 过滤;4 列卡片含标题条) -->
<section class="af-section af-container" th:fragment="links(block)" aria-label="专题网站">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</span>
<h2 th:text="${block.title}">专题网站</h2>
</div>
</div>
<ul class="af-topic-grid">
<th:block th:each="grp : ${block.linkGroups}">
<li class="af-topic" th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" target="_blank" rel="noopener" th:title="${l.name}">
<span class="af-topic-bar" aria-hidden="true"></span>
<span class="af-topic-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14 0 18M12 3c-3 3.5-3 14 0 18"/></svg>
</span>
<span class="af-topic-name" th:text="${l.name}">专题名称</span>
</a>
</li>
</th:block>
</ul>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="af-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="af-empty-hint">内容发布后将在此展示</p>
</div>
</html>
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理" -->
<main class="af-page af-container" id="main-content">
<div class="af-page-card af-svc-guide">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a th:href="${url.serviceList()}">服务中心</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<span class="af-crumb-current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
</nav>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="af-empty af-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="af-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<article class="af-svc-guide-body" th:if="${!svcUnavailable}">
<h1 class="af-article-title" th:text="${item.title}">事项名称</h1>
<div class="af-article-meta">
<span th:if="${!#strings.isEmpty(item.deptName)}"
th:text="${'办理部门:' + item.deptName}">办理部门</span>
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
<time th:if="${item.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
</div>
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
<div class="af-svc-apply" th:if="${applyUrl != null}">
<a class="af-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
<span class="af-svc-apply-hint">进入服务大厅在线办理</span>
</div>
<p class="af-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
<table class="af-svc-info">
<caption class="af-skip-link">事项基本信息</caption>
<tbody>
<tr th:if="${!#strings.isEmpty(item.objectName)}">
<th scope="row">服务对象</th>
<td th:text="${item.objectName}">服务对象</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
<th scope="row">事项类别</th>
<td th:text="${item.categoryName}">事项类别</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
<th scope="row">办理地点</th>
<td th:text="${item.handlePlace}">办理地点</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
<th scope="row">办理时限</th>
<td th:text="${item.handleTimeLimit}">办理时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
<th scope="row">承诺时限</th>
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
<th scope="row">咨询电话</th>
<td th:text="${item.contactPhone}">咨询电话</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
<th scope="row">监督投诉</th>
<td th:text="${item.supervisePhone}">监督投诉电话</td>
</tr>
</tbody>
</table>
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
<h2>办理流程</h2>
<div class="af-article-content" th:utext="${item.processHtml}">办理流程</div>
</section>
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
<h2>所需材料</h2>
<div class="af-article-content" th:utext="${item.materialHtml}">所需材料</div>
</section>
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
<h2>注意事项</h2>
<div class="af-article-content" th:utext="${item.noticeHtml}">注意事项</div>
</section>
<section class="af-svc-section af-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
<h2>相关材料下载</h2>
<ul>
<li th:each="att : ${attachments}">
<a th:href="${url.upload(att.webUrl)}" th:download
th:text="${att.fileName}">材料文件</a>
<span class="af-svc-att-size" th:if="${att.fileSize != null}"
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
</li>
</ul>
</section>
<nav class="af-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
</nav>
</article>
<aside class="af-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
<h2 class="af-section-title">相关服务</h2>
<ul class="af-svc-related-list">
<li th:each="rel : ${relatedItems}">
<a th:href="${rel.url}">
<span class="af-svc-related-name" th:text="${rel.title}">相关事项</span>
<span class="af-svc-related-meta">
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
<span class="af-svc-tag"
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
</span>
</a>
</li>
</ul>
</aside>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="af-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize):
干净切图 + 左下墨绿标题条 -->
<section class="af-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="af-banner-slides">
<a class="af-banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="af-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="af-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="af-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="af-banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:each="block : ${layoutBlocks}">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 三图焦点:图片新闻(focusArticles 必有封面)横排 3 卡 -->
<section class="af-section af-container" aria-label="图片新闻" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
</span>
<h2>图片新闻</h2>
</div>
</div>
<ul class="af-trio">
<li class="af-trio-item" th:each="item : ${focusArticles}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<span class="af-trio-cover">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</span>
<span class="af-trio-cap">
<span class="af-trio-title" th:text="${item.title}">图片新闻标题</span>
<span class="af-trio-meta">
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
</section>
<!-- 头条区:头条推荐(置顶优先)大卡 + 右侧密行 -->
<section class="af-section af-container" aria-label="头条推荐">
<div class="af-headline" th:if="${!#lists.isEmpty(topArticles)}">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M4 5h11a1 1 0 0 1 1 1v13H6a2 2 0 0 1-2-2V5z"/><path d="M16 9h3a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2"/><path d="M7 9h6M7 13h6M7 17h4"/></svg>
</span>
<h2>头条推荐</h2>
</div>
</div>
<div class="af-headline-body"
th:with="rest=${#lists.size(topArticles) > 1 ? topArticles.subList(1, topArticles.size()) : null}">
<div class="af-headline-lead" th:with="lead=${topArticles[0]}">
<a class="af-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="af-lead-meta">
<span class="af-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
<ul class="af-block-list" th:if="${rest != null}">
<li class="af-line" th:each="item : ${rest}">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8,D1-2):通知公告形态(46px 日期方块) -->
<section class="af-section af-container" aria-label="栏目区块">
<div class="af-blocks">
<div class="af-block" th:each="block : ${categoryBlocks}">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M6 3h8l4 4v14H6V3z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>
</span>
<h2 th:text="${block.category.name}">栏目名</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-block-list af-notice-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="af-feat-row" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="af-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="af-feat-body">
<span class="af-feat-title" th:text="${feat.title}">头条标题</span>
<span class="af-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="af-feat-meta">
<span class="af-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
<li class="af-line af-line--date" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="af-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="af-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 列表页(含搜索结果页两用):themeParams['listLayout'] 切换形态——
date=46px 日期方块行(默认,农林官网典型)/ plain=通栏行式(隐藏方块、日期右置) -->
<main class="af-page af-container" id="main-content"
th:classappend="${themeParams['listLayout'] == 'plain'} ? 'af-page--plainlist'">
<div class="af-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="af-side-nav-list">
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="af-page-card">
<nav class="af-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="af-crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="af-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="af-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<span class="af-crumb-current">搜索结果</span>
</nav>
<h1 class="af-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="af-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="af-article-list">
<!-- 有封面:图文行;无封面:日期方块行(plain 形态下自动切通栏行式),不造占位图 -->
<li th:each="item : ${listData.items}">
<div class="af-article-row" th:if="${item.coverImage != null}">
<a class="af-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="af-item-body">
<div class="af-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="af-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="af-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="af-article-line" th:if="${item.coverImage == null}">
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<div class="af-line-main">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="af-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
</div>
<div class="af-line-side">
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-line-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
</li>
</ul>
<div class="af-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="af-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="af-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="af-pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
<a th:classappend="${cur <= 1} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
th:aria-disabled="${cur <= 1}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:aria-label="${'第' + p + '页'}"
th:text="${p}">1</a>
<a th:classappend="${cur >= total} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
th:aria-disabled="${cur >= total}">下一页</a>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query -->
<main class="af-page af-container" id="main-content">
<div class="af-page-card af-svc-page">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<span class="af-crumb-current">服务中心</span>
</nav>
<h1 class="af-page-title">服务中心</h1>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="af-empty af-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="af-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<div class="af-svc-filters"
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
<span class="af-svc-filter-label">服务对象</span>
<div class="af-svc-filter-options">
<a class="af-svc-chip" th:each="opt : ${serviceFilters.objects}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
<span class="af-svc-filter-label">事项类别</span>
<div class="af-svc-filter-options">
<a class="af-svc-chip" th:each="opt : ${serviceFilters.categories}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
<span class="af-svc-filter-label">服务部门</span>
<div class="af-svc-filter-options">
<a class="af-svc-chip" th:each="opt : ${serviceFilters.depts}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
</div>
<div class="af-svc-toolbar" th:if="${!svcUnavailable}">
<form class="af-svc-search" th:action="${url.serviceList()}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
<button type="submit">搜索</button>
</form>
<a class="af-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
</div>
<ul class="af-svc-list" th:if="${!svcUnavailable}">
<li th:each="it : ${serviceList.items}">
<a class="af-svc-item" th:href="${url.serviceItem(it.id)}">
<span class="af-svc-item-cover" th:if="${it.coverImage != null}">
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
</span>
<span class="af-svc-item-body">
<span class="af-svc-item-title">
<span class="af-svc-item-name" th:text="${it.title}">事项名称</span>
<span class="af-svc-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
<span class="af-svc-tag"
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
</span>
<span class="af-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
th:text="${it.summary}">简介</span>
<span class="af-svc-item-meta">
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
<time th:if="${it.publishTime != null}"
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<div class="af-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
<p class="af-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
<p class="af-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
</div>
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
<nav class="af-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
<a th:each="p : ${serviceList.pageUrls}"
th:classappend="${p.active} ? 'active'"
th:href="${p.url}"
th:aria-label="${'第' + p.page + '页'}"
th:aria-current="${p.active} ? 'page'"
th:text="${p.page}">1</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="af-page af-container" id="main-content">
<div class="af-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="af-side-nav-list">
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="af-page-card">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a class="af-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="af-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="af-crumb-current">正文</span>
</nav>
<article class="af-article">
<h1 class="af-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="af-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="af-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="af-empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,30 @@
{
"specVersion": 1,
"name": "agri-forest",
"title": "农林绿(agri-forest)",
"version": "1.0.0",
"author": "yunhai",
"description": "墨绿+麦穗金的农林院校风(设计语言参考信阳农林学院官网)。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--af-primary", "label": "主色(墨绿)", "type": "color", "default": "#1C6E19" },
{ "key": "primaryDark", "cssVar": "--af-primary-dark", "label": "主色加深", "type": "color", "default": "#14510F" },
{ "key": "primaryLight", "cssVar": "--af-primary-light","label": "主色提亮", "type": "color", "default": "#3FA43A" },
{ "key": "accent", "cssVar": "--af-accent", "label": "麦穗金", "type": "color", "default": "#C8A45C" },
{ "key": "navBg", "cssVar": "--af-nav-bg", "label": "主导航底色", "type": "color", "default": "#1C6E19" },
{ "key": "footerBg", "cssVar": "--af-footer-bg", "label": "页脚底色", "type": "color", "default": "#14510F" },
{ "key": "text", "cssVar": "--af-text", "label": "正文文字色", "type": "color", "default": "#2A3327" },
{ "key": "textLight", "cssVar": "--af-text-light", "label": "次要文字色", "type": "color", "default": "#6C7A67" },
{ "key": "bg", "cssVar": "--af-bg", "label": "页面底色", "type": "color", "default": "#F5F8F3" },
{ "key": "cardBg", "cssVar": "--af-card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" },
{ "key": "border", "cssVar": "--af-border", "label": "边框色", "type": "color", "default": "#DDE8DA" },
{ "key": "linkGreen", "cssVar": "--af-link", "label": "链接绿", "type": "color", "default": "#277428" },
{ "key": "listLayout", "label": "列表页形态", "type": "select", "default": "date",
"options": [
{ "value": "date", "label": "日期方块" },
{ "value": "plain", "label": "通栏行式" }
]
}
]
}
@@ -0,0 +1,54 @@
# jade-normal(师大青)
师范院校风皮肤包:以 `default` 为基座派生,设计语言参考信阳师范大学官网(www.xynu.edu.cn,仅学语言不复制实现)——
翡翠青主色 + 暖橙点缀,人文师范气质、温润轻盈。
配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染——派生包不自带区块形态时,
正文会落到 `themes/common` 的通用骨架,换肤就只剩页头页脚(线上事故根因,见《审计验收清单-20260919》)。
## 风格定位
- 主色翡翠青 `#008D3F` / 加深 `#04682E` / 提亮 `#35B371`,点缀暖橙 `#F18D00`(日期徽标数字、强调角标、"我要办理"按钮)。
- 页头三段式:深青顶条(日期+专业院系)→ 白色品牌行(校徽+校名居左,服务中心入口+圆角搜索居右)→
翡翠青通栏主导航(白字,当前/悬停项 primaryLight 底条,≤768px checkbox 纯 CSS 折叠)。
- banner 标题用青色底条(非渐变遮罩);列表主打"46px 橙色日期徽标(日大号+年月小号)"语言。
- 圆角 8/10px、正文行高 1.8、hover 过渡 0.2s ease;无渐变堆砌、无玻璃拟态、无粉紫。
- 页脚:薄荷友链带 + 翡翠深青 `#04682E` 多列(品牌+社媒图标 / 多校区地址+联系版权备案 / 二维码)+ 深色底条。
- 全部 12 个颜色参数可在站点 theme_params 微调,另有 1 个 select 布局参数(见下)。
## 参数取值建议(theme_params)
| key | 默认 | 说明 |
| -- | -- | -- |
| `primary` | `#008D3F` | 翡翠青主色(导航/标题/链接) |
| `primaryDark` | `#04682E` | 加深(顶条/页脚/hover) |
| `primaryLight` | `#35B371` | 提亮(当前栏目底条/描边) |
| `accent` | `#F18D00` | 暖橙点缀(日期徽标/角标) |
| `navBg` | `#008D3F` | 主导航条底色(可与主色分离调) |
| `footerBg` | `#04682E` | 页脚主底色 |
| `text` / `textLight` | `#2E3A33` / `#6E7F75` | 正文/次要文字 |
| `bg` / `cardBg` / `border` | `#F4FAF6` / `#FFFFFF` / `#DFEEE5` | 页面底/卡片/边框 |
| `mintBg` | `#E7F6EE` | 薄荷底(背景带/友链区/hover 面) |
| `listLayout` | `badge` | 列表页形态:`badge` 橙色日期徽标行 / `plain` 通栏行式(日期右置) |
## 首页区块形态(布局模式与回落模式同构)
| homeStyle | 呈现 | 类名 |
| -- | -- | -- |
| `news_grid` | 校园要闻:左图文头条 + 右橙色日期徽标行 + 四图横排;区块背景可取站点 `news_bg_image` | `jn-split` / `jn-date-badge` / `jn-shots` |
| `compact` | 通知公告:46px「日大号+年月小号」橙色徽标行 | `jn-row-badge` |
| `media_focus` | 学术交流/媒体聚焦:来源徽标 + 双列密行 | `jn-src` / `jn-block-list--two` |
| `image_wall` | 教学科研:四列图片墙,青底标题条,hover 抬图 | `jn-wall` |
| (空/未知) | 整宽区块卡:头条 + 行式列表 | `jn-block--flow` |
| `ticker` | 翡翠青要闻速递横条(单行不换行、超长省略,中文不塌列) | `jn-ticker*` |
| `gallery` | 校园映像四图墙 + 「融媒师大」图标行(微信/微博/抖音/B站走 `social_*` 配置,校报/广播台常驻) | `jn-gallery` / `jn-fusion` |
| `links` | 快捷通道图标宫格(6 列内联 SVG 图标卡,青色描边 hover 青底白字) | `jn-quick` |
## 契约要点
- 结构类前缀 `jn-`,CSS 变量前缀 `--jn-`;`:root` 把 `--jn-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
- 页脚消费 docs/12 增强键:`footer_addresses`(经 `site.footerAddressLines` 逐行渲染)、`qr_wechat`/`qr_weibo`、
`social_wechat`/`social_weibo`/`social_douyin`/`social_bilibili`——均有值才渲染,空站点优雅降级。
- 回归防线:`ThemeContractIT`(7 条路由);带 home_layout 的站点首页不落通用骨架(本包 home.html 分发到自家 fragment)。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,88 @@
/* jade-normal(师大青)皮肤包脚本:顶条日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘),无任何外部依赖。
选择器与 main.css / home.html 的 jn-* 类名一一对应;导航折叠由 CSS(checkbox + label)承担,无需 JS。 */
(function () {
/* 顶条日期(本地时间,YYYY年M月D日 星期X) */
var dateEl = document.getElementById('siteDate');
if (dateEl) {
var now = new Date();
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
+ now.getDate() + '日 星期' + week;
}
/* 轮播(.jn-banner,见 home.html) */
var root = document.querySelector('.jn-banner');
var slides = document.querySelectorAll('.jn-banner-slide');
var dots = document.querySelectorAll('.jn-banner-dots button');
var prevBtn = document.querySelector('.jn-banner-arrow.prev');
var nextBtn = document.querySelector('.jn-banner-arrow.next');
if (!slides.length || !root) {
return;
}
var current = 0;
var timer = null;
var INTERVAL = 5000;
function show(index) {
slides[current].classList.remove('active');
if (dots[current]) {
dots[current].classList.remove('active');
}
current = (index + slides.length) % slides.length;
slides[current].classList.add('active');
if (dots[current]) {
dots[current].classList.add('active');
}
}
function start() {
stop();
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, INTERVAL);
}
}
function stop() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
show(index);
start();
});
});
if (prevBtn) {
prevBtn.addEventListener('click', function () {
show(current - 1);
start();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', function () {
show(current + 1);
start();
});
}
root.addEventListener('mouseenter', stop);
root.addEventListener('mouseleave', start);
root.addEventListener('focusin', stop);
root.addEventListener('focusout', start);
root.setAttribute('tabindex', '-1');
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') {
show(current - 1);
start();
} else if (e.key === 'ArrowRight') {
show(current + 1);
start();
}
});
start();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="jn-detail-main jn-container" id="main-content">
<div class="jn-page-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="jn-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="jn-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="jn-side-nav-list">
<li class="jn-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="jn-page-card">
<nav class="jn-breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="current">正文</span>
</nav>
<article class="jn-article">
<h1 class="jn-article-title" th:text="${article.title}">文章标题</h1>
<div class="jn-article-meta">
<time th:if="${article.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
<span th:if="${!#strings.isEmpty(article.authorName)}"
th:text="'作者:' + ${article.authorName}">作者</span>
<span th:if="${!#strings.isEmpty(article.source)}"
th:text="'来源:' + ${article.source}">来源</span>
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
</div>
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
其余字段一律 th:text 转义输出 -->
<div class="jn-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="jn-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}">
<span class="jn-sib-label">上一篇</span>
<span class="jn-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="jn-sib-none prev" th:if="${prevArticle == null}">
<span class="jn-sib-label">上一篇</span>
<span class="jn-sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}">
<span class="jn-sib-label">下一篇</span>
<span class="jn-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="jn-sib-none next" th:if="${nextArticle == null}">
<span class="jn-sib-label">下一篇</span>
<span class="jn-sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- jade-normal 公共页脚(docs/12 个性化页脚):薄荷友链带(分组三态渲染) + 翡翠深青多列主区
(品牌+社媒图标行 / 多校区地址+联系版权备案 / 二维码) + 深色底条(校名)。
基础数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName);
新增消费 config 键:footer_addresses(经 site.footerAddressLines 派生)/qr_wechat/qr_weibo/
social_wechat/social_weibo/social_douyin/social_bilibili(均有值才渲染,空站点优雅降级)。 -->
<footer class="jn-footer-wrap" th:fragment="footer">
<!-- 友情链接(无分组的站整段不渲染;分组无链接时防御性跳过——数据上分组由链接行物化,不可达) -->
<div class="jn-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="jn-footer-links-inner">
<div class="jn-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="jn-group-name" th:text="${group.groupName}">分组</span>
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
<!-- B4 友链 Logo:有 logo 图片+名称并排(渲染期补基准),无 logo 纯文字 -->
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</div>
</div>
</div>
<!-- 主区:品牌+社媒 | 地址/联系/版权/备案 | 二维码(config_json: contact/copyright/beian) -->
<div class="jn-footer">
<div class="jn-footer-inner">
<!-- 品牌列:校徽 + 站名 + 社媒图标(social_* 有值才渲染) -->
<div class="jn-footer-brand">
<div class="jn-footer-logo-row">
<span class="jn-footer-badge" th:if="${site.config['logo'] != null}">
<img class="jn-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="jn-footer-badge" th:if="${site.config['logo'] == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false">
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
fill="currentColor"></path>
</svg>
</span>
<span class="jn-footer-site" th:text="${site.name}">站点名称</span>
</div>
<div class="jn-social" th:if="${site.config['social_wechat'] != null
or site.config['social_weibo'] != null
or site.config['social_douyin'] != null
or site.config['social_bilibili'] != null}">
<a th:if="${site.config['social_wechat'] != null}" class="jn-social-icon"
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/>
</svg>
</a>
<a th:if="${site.config['social_weibo'] != null}" class="jn-social-icon"
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/>
</svg>
</a>
<a th:if="${site.config['social_douyin'] != null}" class="jn-social-icon"
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/>
</svg>
</a>
<a th:if="${site.config['social_bilibili'] != null}" class="jn-social-icon"
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M7.2 4.3 9 6.4h6L16.8 4.3c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/>
</svg>
</a>
</div>
</div>
<!-- 信息列:多校区地址(footer_addresses 已按行拆好)+ 联系/版权/备案 -->
<div class="jn-footer-info">
<ul class="jn-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
</ul>
<p class="jn-footer-line" th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p class="jn-footer-line" th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p class="jn-footer-line" th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
<!-- 二维码列(qr_wechat/qr_weibo 有值才渲染) -->
<div class="jn-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
<figure class="jn-qr" th:if="${site.config['qr_wechat'] != null}">
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
<figcaption>微信扫码</figcaption>
</figure>
<figure class="jn-qr" th:if="${site.config['qr_weibo'] != null}">
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
<figcaption>微博扫码</figcaption>
</figure>
</div>
</div>
<!-- 底条:全站无任何配置信息时仍保留结构完整(空块由 CSS 收敛) -->
<div class="jn-footer-bottom">
<div class="jn-footer-bottom-inner">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</div>
</div>
</footer>
</html>
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- jade-normal 公共 head 片段:SEO(title/description/canonical/og)+ 主题资产(css/main.css + js/main.js
+ favicon 兜底)+ 微调参数 CSS 变量覆盖(themeVarsCss)+ 站点自定义样式(customCss)。
数据绑定与 default 契约一致,仅换皮。 -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
</html>
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- jade-normal 公共头部:深青顶条(日期+专业院系) + 白色品牌行(校徽/站名居左,搜索与工具链接居右)
+ 翡翠青通栏主导航(白字,当前项 primaryLight 底条,≤768px checkbox 纯 CSS 折叠)。
数据绑定与 default 契约一致(site.colleges 空则不渲染院系下拉),仅换皮。 -->
<header class="jn-header" th:fragment="header">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<!-- 顶条:左侧今日日期(main.js 填充);右侧院系子站切换(《院系子站方案》):顶层站取其子站、
院系子站取兄弟站(含自身);无子站整块不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="jn-topbar">
<div class="jn-topbar-inner">
<span class="jn-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="jn-topbar-side" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<div class="jn-colleges">
<span class="jn-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="jn-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="jn-colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 品牌行:校徽+校名居左;右侧服务中心入口 + 站内搜索 -->
<div class="jn-brandbar">
<div class="jn-brandbar-inner">
<div class="jn-brand">
<span class="jn-brand-badge" th:if="${site.config['logo'] != null}">
<img class="jn-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<a class="jn-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<div class="jn-tools">
<a class="jn-tool-link" th:href="${url.serviceList()}">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"></circle>
<path d="M12 8v4.5l3 2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<span>服务中心</span>
</a>
<form class="jn-search" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">
<svg viewBox="0 0 24 24" width="15" height="15" focusable="false" aria-hidden="true">
<path d="M10.5 3a7.5 7.5 0 015.9 12.1l4.3 4.3-1.4 1.4-4.3-4.3A7.5 7.5 0 1110.5 3zm0 2a5.5 5.5 0 100 11 5.5 5.5 0 000-11z" fill="currentColor"></path>
</svg>
<span>搜索</span>
</button>
</form>
</div>
</div>
</div>
<!-- 栏目导航:翡翠青通栏(白字,当前项 primaryLight 底条);外链栏目直接外跳,其余进栏目页(方案 §8.1);
≤768px 由 checkbox + label 纯 CSS 折叠(main.css §5/§21),无 JS 依赖 -->
<nav class="jn-nav" aria-label="栏目导航">
<input type="checkbox" id="jn-nav-fold" class="jn-nav-fold">
<label class="jn-nav-btn" for="jn-nav-fold">栏目菜单</label>
<ul>
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:悬停展开(.jn-sub 样式见 assets/css/main.css);当前项高亮(docs/11) -->
<ul class="jn-sub" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}"
th:classappend="${sub.id == currentCategoryId} ? 'active'">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,316 @@
<!--
jade-normal(师大青)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
结构复用主题 main.css 第 7-16 节语汇(jn-block / jn-block-head / jn-date-badge / jn-block-item /
jn-wall / jn-ticker / jn-fusion / jn-quick),仅布局模式特有的排布(双栏要闻、四图横排、速递条、
快捷图标宫格、融媒图标行)另立第 10-15 节。
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background/
subtitle/linkGroups),与回落模式 categoryBlocks 兼容;区块标题一律取 category.name(两种模式都有值)。
注意:th:replace(优先级 100)先于 th:if(300)执行——空态守卫与形态分发都必须把 replace 放子元素。
-->
<!-- 形态一:校园要闻(homeStyle=news_grid)——左图文头条 + 右橙色日期徽标行 + 四图横排(信阳师大要闻区语言) -->
<section class="jn-section jn-container jn-news-sec" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="jn-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="jn-block jn-block--flow"
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
<div class="jn-block-head">
<h2 th:text="${block.category.name}">校园要闻</h2>
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
</div>
<div class="jn-split" th:if="${lead != null}">
<div class="jn-block-featured">
<a th:href="${url.article(lead.id)}" class="jn-feat-a--stack">
<span class="jn-feat-cover jn-feat-cover--row" th:if="${lead.coverImage != null}">
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
</span>
<span class="jn-feat-body">
<span class="jn-feat-title" th:text="${lead.title}">头条标题</span>
<span class="jn-feat-summary" th:if="${!#strings.isEmpty(lead.summary)}"
th:text="${lead.summary}">摘要</span>
<span class="jn-feat-meta">
<span class="jn-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</div>
<ul class="jn-block-list jn-split-side" th:if="${rest != null}">
<li class="jn-block-item jn-row-badge" th:each="item : ${rest}">
<span class="jn-date-badge" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
</div>
<!-- 四图横排:取余下有封面的条目,四列网格,不足即少几格、超出自动换行 -->
<ul class="jn-shots" th:if="${rest != null}">
<li class="jn-shot" th:each="item : ${rest}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="jn-shot-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列密行 -->
<section class="jn-section jn-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="jn-block jn-block--flow">
<div class="jn-block-head">
<h2 th:text="${block.category.name}">媒体聚焦</h2>
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
</div>
<ul class="jn-block-list jn-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="jn-block-item" th:each="item : ${block.items}">
<span class="jn-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="jn-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:通知公告(homeStyle=compact)——橙色日期徽标行(46px:日大号 + 年月小号) -->
<section class="jn-section jn-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="jn-block jn-block--flow">
<div class="jn-block-head">
<h2 th:text="${block.category.name}">通知公告</h2>
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
</div>
<ul class="jn-block-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="jn-block-item jn-row-badge" th:each="item : ${block.items}">
<span class="jn-date-badge" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:图片墙(homeStyle=image_wall)——四列图片墙,仅取有封面条目,青底标题条 -->
<section class="jn-section jn-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="jn-block-head">
<h2 th:text="${block.category.name}">图片新闻</h2>
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
</div>
<ul class="jn-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="jn-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="jn-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 形态五:默认卡(homeStyle 未设/未知)——整宽头条 + 行式列表 -->
<section class="jn-section jn-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="jn-block jn-block--flow">
<div class="jn-block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
</div>
<div class="jn-block-featured" th:if="${!#lists.isEmpty(block.items)}"
th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="jn-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="jn-feat-body">
<span class="jn-feat-title" th:text="${feat.title}">头条标题</span>
<span class="jn-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
<span class="jn-feat-meta">
<span class="jn-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</div>
<ul class="jn-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="jn-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="jn-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="jn-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="jn-container jn-ticker-inner">
<span class="jn-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="jn-ticker-list">
<li th:each="item : ${block.items}">
<a class="jn-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:校园映像 + 融媒师大(block.items = 轮播位 gallery;融媒图标行消费站点 social_* 配置) -->
<section class="jn-section jn-container jn-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
th:if="${!#lists.isEmpty(block.items)}">
<div class="jn-block-head">
<h2 th:text="${block.title}">校园映像</h2>
</div>
<p class="jn-gallery-sub" th:if="${!#strings.isEmpty(block['subtitle'])}" th:text="${block['subtitle']}">
Campus Gallery
</p>
<ul class="jn-wall">
<li class="jn-wall-item" th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
<span class="jn-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
<!-- 融媒师大:微信/微博/抖音/B站走站点 social_* 配置(有值才渲染),校报/广播台为常驻静态卡 -->
<div class="jn-fusion">
<p class="jn-fusion-label">融媒师大</p>
<div class="jn-fusion-row">
<a th:if="${site.config['social_wechat'] != null}" class="jn-fusion-item"
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
<span class="jn-fusion-ico">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/>
</svg>
</span>
<span class="jn-fusion-name">微信</span>
</a>
<a th:if="${site.config['social_weibo'] != null}" class="jn-fusion-item"
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
<span class="jn-fusion-ico">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/>
</svg>
</span>
<span class="jn-fusion-name">微博</span>
</a>
<a th:if="${site.config['social_douyin'] != null}" class="jn-fusion-item"
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
<span class="jn-fusion-ico">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/>
</svg>
</span>
<span class="jn-fusion-name">抖音</span>
</a>
<a th:if="${site.config['social_bilibili'] != null}" class="jn-fusion-item"
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
<span class="jn-fusion-ico">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M7.2 4.3 9 6.4h6L16.8 4.3c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/>
</svg>
</span>
<span class="jn-fusion-name">哔哩哔哩</span>
</a>
<span class="jn-fusion-item" aria-label="校报" title="校报">
<span class="jn-fusion-ico">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M6 3h11a1 1 0 0 1 1 1v14a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4a1 1 0 0 1 1-1zm1 2v2h9V5H7zm0 4v2h4V9H7zm6 0v2h3V9h-3zM7 13v2h4v-2H7zm6 0v2h3v-2h-3zM7 17v2h4v-2H7zm6 0v2h3a1 1 0 0 0 1-1v-1h-4z"/>
</svg>
</span>
<span class="jn-fusion-name">校报</span>
</span>
<span class="jn-fusion-item" aria-label="校园广播台" title="校园广播台">
<span class="jn-fusion-ico">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M12 4a1 1 0 0 1 .9.6l3.6 8a1 1 0 0 1-1.3 1.3l-2.3-1v3.6a3.5 3.5 0 1 1-2 0v-3.6l-2.3 1a1 1 0 0 1-1.3-1.3l3.6-8A1 1 0 0 1 12 4zm-1 12.6a1.5 1.5 0 1 0 2 0 1.5 1.5 0 0 0-2 0z"/>
</svg>
</span>
<span class="jn-fusion-name">广播台</span>
</span>
</div>
</div>
</section>
<!-- 布局模式:快捷通道图标宫格(block.linkGroups 由渲染端按 group 过滤;对应信阳师大「信息门户/协同办公/教务管理/网络教学/财务平台/校园卡服务」快捷入口条) -->
<section class="jn-section jn-container" th:fragment="links(block)" aria-label="快捷通道">
<div class="jn-block jn-block--flow">
<div class="jn-block-head">
<h2 th:text="${block.title}">快捷通道</h2>
</div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="jn-quick-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
<ul class="jn-quick-grid">
<li class="jn-quick-item" th:each="l, stat : ${grp.links}">
<a th:href="${l.linkUrl}" th:title="${l.name}" target="_blank" rel="noopener">
<span class="jn-quick-ico">
<th:block th:switch="${stat.index % 6}">
<svg th:case="0" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2"></rect>
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2"></rect>
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2"></rect>
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2"></rect>
</svg>
<svg th:case="1" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<rect x="3" y="8" width="18" height="11" rx="2"></rect>
<path d="M9 8V6.5A1.5 1.5 0 0 1 10.5 5h3A1.5 1.5 0 0 1 15 6.5V8"></path>
<path d="M3 12.5h18"></path>
</svg>
<svg th:case="2" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H19v14H5.5A1.5 1.5 0 0 0 4 19.5v-14z"></path>
<path d="M4 19.5A1.5 1.5 0 0 1 5.5 18H19v2H5.5"></path>
<path d="M8 8h7M8 11.5h5"></path>
</svg>
<svg th:case="3" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<rect x="3" y="4.5" width="18" height="12" rx="1.5"></rect>
<path d="M9 20h6M12 16.5V20"></path>
<path d="M10 9l2.5 3L15 9" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<svg th:case="4" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<ellipse cx="12" cy="6" rx="7" ry="2.8"></ellipse>
<path d="M5 6v6c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8V6"></path>
<path d="M5 12v6c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8v-6"></path>
</svg>
<svg th:case="*" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
<rect x="2.8" y="5.5" width="18.4" height="13" rx="2.2"></rect>
<path d="M2.8 9.8h18.4"></path>
<path d="M6.5 14.5h5"></path>
</svg>
</th:block>
</span>
<span class="jn-quick-name" th:text="${l.name}">快捷入口</span>
</a>
</li>
</ul>
</th:block>
</div>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="jn-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="jn-empty-hint">内容发布后将在此展示</p>
</div>
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理" -->
<main class="jn-detail-main jn-container" id="main-content">
<div class="jn-page-card jn-svc-guide">
<nav class="jn-breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<a th:href="${url.serviceList()}">服务中心</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
</nav>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="jn-empty jn-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="jn-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<article class="jn-svc-guide-body" th:if="${!svcUnavailable}">
<h1 class="jn-article-title" th:text="${item.title}">事项名称</h1>
<div class="jn-article-meta">
<span th:if="${!#strings.isEmpty(item.deptName)}"
th:text="${'办理部门:' + item.deptName}">办理部门</span>
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
<time th:if="${item.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
</div>
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
<div class="jn-svc-apply" th:if="${applyUrl != null}">
<a class="jn-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
<span class="jn-svc-apply-hint">进入服务大厅在线办理</span>
</div>
<p class="jn-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
<table class="jn-svc-info">
<caption class="skip-link">事项基本信息</caption>
<tbody>
<tr th:if="${!#strings.isEmpty(item.objectName)}">
<th scope="row">服务对象</th>
<td th:text="${item.objectName}">服务对象</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
<th scope="row">事项类别</th>
<td th:text="${item.categoryName}">事项类别</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
<th scope="row">办理地点</th>
<td th:text="${item.handlePlace}">办理地点</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
<th scope="row">办理时限</th>
<td th:text="${item.handleTimeLimit}">办理时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
<th scope="row">承诺时限</th>
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
<th scope="row">咨询电话</th>
<td th:text="${item.contactPhone}">咨询电话</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
<th scope="row">监督投诉</th>
<td th:text="${item.supervisePhone}">监督投诉电话</td>
</tr>
</tbody>
</table>
<section class="jn-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
<h2>办理流程</h2>
<div class="jn-article-content" th:utext="${item.processHtml}">办理流程</div>
</section>
<section class="jn-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
<h2>所需材料</h2>
<div class="jn-article-content" th:utext="${item.materialHtml}">所需材料</div>
</section>
<section class="jn-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
<h2>注意事项</h2>
<div class="jn-article-content" th:utext="${item.noticeHtml}">注意事项</div>
</section>
<section class="jn-svc-section jn-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
<h2>相关材料下载</h2>
<ul>
<li th:each="att : ${attachments}">
<a th:href="${url.upload(att.webUrl)}" th:download
th:text="${att.fileName}">材料文件</a>
<span class="jn-svc-att-size" th:if="${att.fileSize != null}"
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
</li>
</ul>
</section>
<nav class="jn-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
</nav>
</article>
<aside class="jn-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
<h2 class="jn-section-title">相关服务</h2>
<ul class="jn-svc-related-list">
<li th:each="rel : ${relatedItems}">
<a th:href="${rel.url}">
<span class="jn-svc-related-name" th:text="${rel.title}">相关事项</span>
<span class="jn-svc-related-meta">
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
<span class="jn-svc-tag"
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
</span>
</a>
</li>
</ul>
</aside>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="jn-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize) -->
<section class="jn-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="jn-banner-slides">
<a class="jn-banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="jn-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="jn-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="jn-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="jn-banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:each="block : ${layoutBlocks}">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 头条区:头条推荐(置顶优先) + 图片新闻(有封面文章) -->
<section class="jn-section jn-container" aria-label="头条与图片新闻">
<div class="jn-headline">
<div class="jn-block jn-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
<div class="jn-block-head">
<h2>头条推荐</h2>
</div>
<ul class="jn-headline-list">
<li class="jn-headline-lead" th:with="lead=${topArticles[0]}">
<a class="jn-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="jn-lead-meta">
<span class="jn-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</li>
<li th:each="item, stat : ${topArticles}" th:if="${stat.index > 0}">
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</div>
<div class="jn-block jn-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="jn-block-head">
<h2>图片新闻</h2>
</div>
<a class="jn-focus-lead" th:with="cover=${focusArticles[0]}"
th:href="${url.article(cover.id)}">
<span class="jn-focus-cover">
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
</span>
<span class="jn-focus-title" th:text="${cover.title}">图片新闻标题</span>
</a>
<ul class="jn-focus-rows">
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
<a th:href="${url.article(row.id)}">
<span class="jn-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
<span class="jn-row-title" th:text="${row.title}">图片新闻标题</span>
</a>
</li>
</ul>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8,D1-2) -->
<section class="jn-section jn-container" aria-label="栏目区块">
<div class="jn-blocks">
<div class="jn-block" th:each="block : ${categoryBlocks}">
<div class="jn-block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
</div>
<ul class="jn-block-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="jn-block-featured" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="jn-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="jn-feat-body">
<span class="jn-feat-title" th:text="${feat.title}">头条标题</span>
<span class="jn-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="jn-feat-meta">
<span class="jn-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
<li class="jn-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="jn-date-badge" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="jn-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="jn-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 列表页(含搜索结果页两用):themeParams['listLayout'] 切换形态——
badge(默认)橙色 46px 日期徽标行 / plain 通栏行式(日期右置),见 main.css §17 -->
<main class="jn-list-main jn-container" id="main-content">
<div class="jn-page-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="jn-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="jn-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="jn-side-nav-list">
<li class="jn-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="jn-page-card">
<nav class="jn-breadcrumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="jn-breadcrumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current">搜索结果</span>
</nav>
<h1 class="jn-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="jn-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="jn-article-list"
th:classappend="${themeParams['listLayout'] == 'plain'} ? 'jn-list--plain' : 'jn-list--badge'">
<!-- 有封面:图文行;无封面:文字行(与图文行同等用心,不造占位图) -->
<li th:each="item : ${listData.items}">
<div class="jn-item jn-item--pic" th:if="${item.coverImage != null}">
<a class="jn-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="jn-item-body">
<div class="jn-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="jn-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="jn-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="jn-item jn-item--plain" th:if="${item.coverImage == null}">
<!-- 日期徽标(badge 形态默认显示;plain 形态由 CSS 隐藏、时间右置) -->
<span class="jn-date-badge" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<div class="jn-item-body">
<div class="jn-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="jn-item-time" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
<p class="jn-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="jn-item-meta">
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
</li>
</ul>
<div class="jn-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="jn-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="jn-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="jn-pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
<a th:classappend="${cur <= 1} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
th:aria-disabled="${cur <= 1}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:aria-label="${'第' + p + '页'}"
th:text="${p}">1</a>
<a th:classappend="${cur >= total} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
th:aria-disabled="${cur >= total}">下一页</a>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query -->
<main class="jn-list-main jn-container" id="main-content">
<div class="jn-page-card jn-svc-page">
<nav class="jn-breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current">服务中心</span>
</nav>
<h1 class="jn-page-title">服务中心</h1>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="jn-empty jn-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="jn-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<div class="jn-svc-filters"
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
<div class="jn-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
<span class="jn-svc-filter-label">服务对象</span>
<div class="jn-svc-filter-options">
<a class="jn-svc-chip" th:each="opt : ${serviceFilters.objects}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="jn-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
<span class="jn-svc-filter-label">事项类别</span>
<div class="jn-svc-filter-options">
<a class="jn-svc-chip" th:each="opt : ${serviceFilters.categories}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="jn-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
<span class="jn-svc-filter-label">服务部门</span>
<div class="jn-svc-filter-options">
<a class="jn-svc-chip" th:each="opt : ${serviceFilters.depts}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
</div>
<div class="jn-svc-toolbar" th:if="${!svcUnavailable}">
<form class="jn-svc-search" th:action="${url.serviceList()}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
<button type="submit">搜索</button>
</form>
<a class="jn-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
</div>
<ul class="jn-svc-list" th:if="${!svcUnavailable}">
<li th:each="it : ${serviceList.items}">
<a class="jn-svc-item" th:href="${url.serviceItem(it.id)}">
<span class="jn-svc-item-cover" th:if="${it.coverImage != null}">
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
</span>
<span class="jn-svc-item-body">
<span class="jn-svc-item-title">
<span class="jn-svc-item-name" th:text="${it.title}">事项名称</span>
<span class="jn-svc-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
<span class="jn-svc-tag"
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
</span>
<span class="jn-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
th:text="${it.summary}">简介</span>
<span class="jn-svc-item-meta">
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
<time th:if="${it.publishTime != null}"
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<div class="jn-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
<p class="jn-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
<p class="jn-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
</div>
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
<nav class="jn-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
<a th:each="p : ${serviceList.pageUrls}"
th:classappend="${p.active} ? 'active'"
th:href="${p.url}"
th:aria-label="${'第' + p.page + '页'}"
th:aria-current="${p.active} ? 'page'"
th:text="${p.page}">1</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="jn-detail-main jn-container" id="main-content">
<div class="jn-page-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="jn-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="jn-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="jn-side-nav-list">
<li class="jn-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="jn-page-card">
<nav class="jn-breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="current">正文</span>
</nav>
<article class="jn-article">
<h1 class="jn-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="jn-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="jn-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="jn-empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,30 @@
{
"specVersion": 1,
"name": "jade-normal",
"title": "师大青(jade-normal)",
"version": "1.0.0",
"author": "yunhai",
"description": "翡翠青+暖橙点缀的师范院校风(设计语言参考信阳师范大学官网)。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--jn-primary", "label": "主色·翡翠青", "type": "color", "default": "#008D3F" },
{ "key": "primaryDark", "cssVar": "--jn-primary-dark", "label": "主色加深", "type": "color", "default": "#04682E" },
{ "key": "primaryLight", "cssVar": "--jn-primary-light","label": "主色提亮", "type": "color", "default": "#35B371" },
{ "key": "accent", "cssVar": "--jn-accent", "label": "点缀橙·日期徽标", "type": "color", "default": "#F18D00" },
{ "key": "navBg", "cssVar": "--jn-nav-bg", "label": "主导航条底色", "type": "color", "default": "#008D3F" },
{ "key": "footerBg", "cssVar": "--jn-footer-bg", "label": "页脚底色", "type": "color", "default": "#04682E" },
{ "key": "text", "cssVar": "--jn-text", "label": "正文文字色", "type": "color", "default": "#2E3A33" },
{ "key": "textLight", "cssVar": "--jn-text-light", "label": "次要文字色", "type": "color", "default": "#6E7F75" },
{ "key": "bg", "cssVar": "--jn-bg", "label": "页面底色", "type": "color", "default": "#F4FAF6" },
{ "key": "cardBg", "cssVar": "--jn-card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" },
{ "key": "border", "cssVar": "--jn-border", "label": "边框色", "type": "color", "default": "#DFEEE5" },
{ "key": "mintBg", "cssVar": "--jn-mint-bg", "label": "薄荷底·背景带", "type": "color", "default": "#E7F6EE" },
{ "key": "listLayout", "label": "列表页形态", "type": "select", "default": "badge",
"options": [
{ "value": "badge", "label": "日期徽标行(橙色日徽)" },
{ "value": "plain", "label": "通栏行式(日期右置)" }
]
}
]
}
@@ -0,0 +1,56 @@
# poly-blue(理工蓝)
理工院校风皮肤包:以 `default` 为基座派生,配色与首页区块形态按浙江理工大学官网(www.zstu.edu.cn)设计语言重做
(仅学设计语言,不复制实现)。配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染——
派生包不自带区块形态时,正文会落到 `themes/common` 的通用骨架,换肤就只剩页头页脚(线上事故根因,见《审计验收清单-20260919》)。
## 风格定位
- 主色深宝蓝 `#0558a5` + 加深 `#124489` + 提亮 `#258cff` + 活力橙 `#ff7b00`(热词/来源徽标/「更多」与 hover 强调)+ 绿色小标签 `#1c7b46`。
- 头部为「深宝蓝通栏品牌行(校名 + 受众标签 + 搜索框带热门搜索词)+ 白底吸顶主导航(当前栏目主色下划线)」;
页脚深宝蓝 `#124489` 多列(品牌+社媒 / 多校区地址 / 二维码位)+ 更深色 `#0b2f5c` 底条。
- 工艺:全局 radius 6px、间距阶梯 4/8/12/16/24/32/48、字号阶梯 12-36、hover 过渡 0.2s ease、
焦点态 outline 2px primaryLight、卡片 hover 阴影抬升;渐变仅 2 处且均为功能性文字遮罩(轮播标题/图片墙标题);
无玻璃拟态、无粉紫、无大圆角。列表无封面行用 46px 日期方块(日大号 + 年月小号,主色底白字)。
- `headerStyle` select 参数:`banner`(默认,通栏横幅头图完整形态)/ `plain`(收起受众行与热词、压低品牌行的简洁顶栏)。
## 参数取值建议(theme_params,13 个)
| key | 默认 | 建议 |
| -- | -- | -- |
| `primary` | `#0558a5` | 品牌行/下划线/日期方块主色;改浅不建议超过 `#1a6cc5`,否则白字对比不足 |
| `primaryDark` | `#124489` | 页脚主底;与 footerBg 联动改 |
| `primaryLight` | `#258cff` | hover 链接/焦点描边;与 primary 拉开明度差 |
| `accent` | `#ff7b00` | 活力橙:热词圆点/来源徽标/更多按钮 hover;可换校方第二识别色(如 `#e60012` 红),保持高饱和 |
| `secondary` | `#1c7b46` | 绿色小标签(可在线办理等正向态),建议保持绿系 |
| `navBg` | `#ffffff` | 主导航底色;保持白或极浅色,深色会破坏「白底吸顶」形态 |
| `footerBg` | `#124489` | 页脚主底;底条为固定更深色 `#0b2f5c`(派生令牌) |
| `text` / `textLight` | `#2f3a47` / `#708090` | 正文/次要文字 |
| `bg` | `#f5f8fc` | 页面底色;偏蓝灰的浅底最有「理工」味,可换 `#f7f9fb` 提亮 |
| `cardBg` / `border` | `#ffffff` / `#e2e8f2` | 卡片底/分隔线 |
| `headerStyle` | `banner` | 结构开关:`plain` = 简洁顶栏(受众行/热词隐藏、品牌行压低) |
## 首页区块形态(布局模式与回落模式同构)
| homeStyle / type | 呈现 | 类名 |
| -- | -- | -- |
| `news_grid` | 双栏要闻:左 3 份大图文头条卡(16:9 封面 + 标题 + 摘要 + 日期/来源元信息)、右 2 份校园经纬紧凑行(圆点 + 日期右对齐);区块背景可取站点 `news_bg_image` | `pb-split` / `pb-news-*` / `pb-brief-*` |
| `compact` | 通知公告:46px 日期方块(日大号 + 年月小号,主色底白字)+ 标题行 | `pb-notice-item` / `pb-date-chip` |
| `media_focus` | 媒体理工:来源徽标(活力橙小 tag)+ 双列密行 | `pb-media-*` / `pb-src--tag` |
| `image_wall` | 四列图片墙:封面 + 底部渐变遮罩标题,hover 抬图 | `pb-wall` |
| (空/未知) | 整宽区块卡:头条 + 行式列表 | `pb-block--flow` |
| `ticker` | 主蓝要闻速递横条(单行不换行、超长省略,中文不塌列) | `pb-ticker*` |
| `gallery` | 线上展厅:居中双语文区头 + 四列图墙 | `pb-gallery` |
| `links` | 快捷通道:图标宫格卡(主色描边浅底,整卡 hover 反白填色) | `pb-channels` / `pb-channel` |
## 契约要点
- 结构类前缀 `pb-`,CSS 变量前缀 `--pb-`;`:root` 把 `--pb-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
使 `themes/common` 兜底骨架与站点 `theme_params` 换色能穿透到首页区块。
- 品牌行受众标签(考生/在校生/教职工/校友)为静态标签位:CMS 暂无受众分组数据源,不造空链接;
热门搜索词经 `url.search(词, null)` 生成真实搜索链接。
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
- 绝对定位背景带层序:`.pb-sec-bg` z-index:0,其后首个 `.pb-block` z-index:1。
- 响应式:≤992 收起左侧栏并单列;≤768 品牌行纵排 + 汉堡抽屉 + 二级手风琴;网格一律 `minmax(0,1fr)`(390px 不溢出)。
- 门禁:`ThemeContractIT`(7 条路由)+ `./verify-theme.sh poly-blue`;受众标签/热词为静态文案属预期,非死链。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,192 @@
/* poly-blue(理工蓝)皮肤包脚本:顶条日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘/触摸)
+ 白底主导航吸顶 + 移动端抽屉与二级手风琴。与 default 同源逻辑,无任何外部依赖。 */
(function () {
'use strict';
/* ---------- 顶条日期(本地时间,YYYY年M月D日 星期X;元素在深宝蓝品牌行) ---------- */
var dateEl = document.getElementById('siteDate');
if (dateEl) {
var now = new Date();
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
+ now.getDate() + '日 星期' + week;
}
/* ---------- 白底主导航吸顶(桌面端;≤768 不吸顶,交还给抽屉形态) ---------- */
var nav = document.getElementById('pbNav');
var DESKTOP_MIN = 769;
var navBaseTop = 0;
var holder = null;
function unlockNav() {
if (nav) { nav.classList.remove('pb-nav--fixed'); }
if (holder && holder.parentNode) {
holder.parentNode.removeChild(holder);
}
holder = null;
}
function lockNav() {
if (!nav) { return; }
if (!nav.classList.contains('pb-nav--fixed')) {
holder = document.createElement('div');
holder.className = 'pb-nav-holder';
holder.style.height = nav.offsetHeight + 'px';
nav.parentNode.insertBefore(holder, nav);
nav.classList.add('pb-nav--fixed');
} else if (holder) {
holder.style.height = nav.offsetHeight + 'px';
}
}
function measureNavTop() {
if (!nav) { return; }
unlockNav();
navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset;
}
function onScrollNav() {
if (window.innerWidth < DESKTOP_MIN) {
unlockNav();
return;
}
if (window.pageYOffset > navBaseTop) {
lockNav();
} else {
unlockNav();
}
}
if (nav) {
measureNavTop();
window.addEventListener('scroll', onScrollNav, { passive: true });
var resizeTimer = null;
window.addEventListener('resize', function () {
if (resizeTimer) { window.clearTimeout(resizeTimer); }
resizeTimer = window.setTimeout(function () {
measureNavTop();
onScrollNav();
}, 150);
});
}
/* ---------- 移动端抽屉 + 二级手风琴 ---------- */
var navToggle = document.querySelector('.pb-nav-toggle');
var navList = document.querySelector('.pb-nav-list');
if (navToggle && navList) {
navToggle.addEventListener('click', function () {
var open = navList.classList.toggle('pb-open');
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
/* 窄屏下:点击带二级的项先展开面板,再点才跳转;宽屏交还给 CSS hover */
if (navList) {
navList.addEventListener('click', function (e) {
if (window.innerWidth >= DESKTOP_MIN) { return; }
var link = e.target.closest ? e.target.closest('a') : null;
if (!link) { return; }
var li = link.parentElement;
if (!li || !li.classList.contains('has-sub')) { return; }
var sub = li.querySelector('.pb-sub');
if (!sub) { return; }
if (!li.classList.contains('pb-sub-open')) {
e.preventDefault();
li.classList.add('pb-sub-open');
}
});
}
/* ---------- 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘) ---------- */
var root = document.querySelector('.pb-banner');
var slides = document.querySelectorAll('.pb-banner-slide');
var dots = document.querySelectorAll('.pb-banner-dots button');
var prevBtn = document.querySelector('.pb-banner-arrow.prev');
var nextBtn = document.querySelector('.pb-banner-arrow.next');
if (!slides.length || !root) {
return;
}
var current = 0;
var timer = null;
var INTERVAL = 5000;
function show(index) {
slides[current].classList.remove('active');
if (dots[current]) {
dots[current].classList.remove('active');
}
current = (index + slides.length) % slides.length;
slides[current].classList.add('active');
if (dots[current]) {
dots[current].classList.add('active');
}
}
function start() {
stop();
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, INTERVAL);
}
}
function stop() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
show(index);
start();
});
});
if (prevBtn) {
prevBtn.addEventListener('click', function () {
show(current - 1);
start();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', function () {
show(current + 1);
start();
});
}
root.addEventListener('mouseenter', stop);
root.addEventListener('mouseleave', start);
root.addEventListener('focusin', stop);
root.addEventListener('focusout', start);
root.setAttribute('tabindex', '-1');
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') {
show(current - 1);
start();
} else if (e.key === 'ArrowRight') {
show(current + 1);
start();
}
});
/* 触摸滑动切页 */
var touchX = null;
root.addEventListener('touchstart', function (e) {
touchX = e.changedTouches[0].clientX;
stop();
}, { passive: true });
root.addEventListener('touchend', function (e) {
if (touchX === null) { return; }
var dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 40) {
show(dx < 0 ? current + 1 : current - 1);
}
touchX = null;
start();
}, { passive: true });
start();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="pb-page pb-container" id="main-content">
<div class="pb-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="pb-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="pb-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="pb-side-nav-list">
<li class="pb-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="pb-page-card">
<nav class="pb-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="pb-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="pb-crumb-current">正文</span>
</nav>
<article class="pb-article">
<h1 class="pb-article-title" th:text="${article.title}">文章标题</h1>
<div class="pb-article-meta">
<time th:if="${article.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
<span th:if="${!#strings.isEmpty(article.authorName)}"
th:text="'作者:' + ${article.authorName}">作者</span>
<span th:if="${!#strings.isEmpty(article.source)}"
th:text="'来源:' + ${article.source}">来源</span>
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
</div>
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
其余字段一律 th:text 转义输出 -->
<div class="pb-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="pb-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}">
<span class="pb-sib-label">上一篇</span>
<span class="pb-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="pb-sib-none prev" th:if="${prevArticle == null}">
<span class="pb-sib-label">上一篇</span>
<span class="pb-sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}">
<span class="pb-sib-label">下一篇</span>
<span class="pb-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="pb-sib-none next" th:if="${nextArticle == null}">
<span class="pb-sib-label">下一篇</span>
<span class="pb-sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- poly-blue 公共页脚:深宝蓝多列(品牌+社媒 / 地址联系版权备案 / 二维码位)+ 友链分组三态 + 更深色底条。
基础数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName);
页脚增强键(docs/12):footer_addresses(经 site.footerAddressLines 派生)/qr_wechat/qr_weibo/
social_wechat/social_weibo/social_douyin/social_bilibili——均有值才渲染,空站点优雅降级。 -->
<footer class="pb-footer" th:fragment="footer">
<div class="pb-container pb-footer-main">
<!-- 品牌列:校徽 + 站名 + 社媒图标阵列(有任一社媒键才渲染) -->
<div class="pb-footer-brand">
<div class="pb-footer-logo-row">
<img th:if="${site.config['logo'] != null}" class="pb-footer-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<span class="pb-footer-site" th:text="${site.name}">站点名称</span>
</div>
<div class="pb-social" th:if="${site.config['social_wechat'] != null
or site.config['social_weibo'] != null
or site.config['social_douyin'] != null
or site.config['social_bilibili'] != null}">
<a th:if="${site.config['social_wechat'] != null}" class="pb-social-icon"
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/>
</svg>
</a>
<a th:if="${site.config['social_weibo'] != null}" class="pb-social-icon"
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/>
</svg>
</a>
<a th:if="${site.config['social_douyin'] != null}" class="pb-social-icon"
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/>
</svg>
</a>
<a th:if="${site.config['social_bilibili'] != null}" class="pb-social-icon"
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M7.2 4.3 9 6.4h6l1.8-2.1c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/>
</svg>
</a>
</div>
</div>
<!-- 地址/联系列:多校区地址(已按行拆好)+ 联系/版权/备案(空键逐行收敛) -->
<div class="pb-footer-info">
<ul class="pb-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
</ul>
<p class="pb-footer-line" th:if="${site.config['contact'] != null}">
<i class="pb-dot" aria-hidden="true"></i><span th:text="${site.config['contact']}">联系方式</span>
</p>
<p class="pb-footer-line" th:if="${site.config['copyright'] != null}">
<i class="pb-dot" aria-hidden="true"></i><span th:text="${site.config['copyright']}">版权信息</span>
</p>
<p class="pb-footer-line" th:if="${site.config['beian'] != null}">
<i class="pb-dot" aria-hidden="true"></i><span th:text="${site.config['beian']}">备案号</span>
</p>
</div>
<!-- 二维码位(qr_wechat/qr_weibo 有值才渲染) -->
<div class="pb-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
<figure class="pb-qr" th:if="${site.config['qr_wechat'] != null}">
<span class="pb-qr-frame"><img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码"></span>
<figcaption>微信扫码</figcaption>
</figure>
<figure class="pb-qr" th:if="${site.config['qr_weibo'] != null}">
<span class="pb-qr-frame"><img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码"></span>
<figcaption>微博扫码</figcaption>
</figure>
</div>
</div>
<!-- 友情链接(浅色隔层:无分组整段不渲染;分组无链接时防御性跳过——数据上分组由链接行物化,不可达) -->
<div class="pb-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="pb-container pb-footer-links-inner">
<div class="pb-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="pb-group-name" th:text="${group.groupName}">分组</span>
<p class="pb-links-row">
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="pb-link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</p>
</div>
</div>
</div>
<!-- 底条:更深色;全站无任何配置信息时仍保留结构完整(空块由 CSS 收敛) -->
<div class="pb-footer-bottom">
<div class="pb-container pb-footer-bottom-inner">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</div>
</footer>
</html>
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- poly-blue 公共 head 片段:SEO(title/description/canonical)+ 主题资产(契约 §3.1;数据绑定与 default 同构) -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
</html>
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- poly-blue 公共头部(设计语言参考浙江理工大学官网):深宝蓝通栏品牌行(校徽/校名 + 右侧日期/专业院系/受众链接/
搜索框带热门搜索词)+ 白底吸顶主导航(当前栏目主色下划线高亮,hover 提亮)。
数据绑定与 default 契约一致(colleges/logo/site.name/url.search/nav 高亮三件套),仅换皮;
headerStyle=plain 时收起受众行与热词、压低品牌行(root class 切换,CSS 消费)。 -->
<header class="pb-header"
th:fragment="header"
th:classappend="${themeParams['headerStyle'] == 'plain'} ? 'pb-header--plain'">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<!-- 深宝蓝通栏品牌行:左=校徽+校名;右=日期 + 专业院系下拉 + 受众标签(静态标签位,无数据源不造假链接) -->
<div class="pb-brandbar">
<div class="pb-brandbar-inner">
<div class="pb-brand">
<span class="pb-brand-badge" th:if="${site.config['logo'] != null}">
<img class="pb-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="pb-brand-badge pb-brand-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28" focusable="false">
<path d="M12 2 3 6v6c0 5 3.8 8.6 9 10 5.2-1.4 9-5 9-10V6l-9-4zm0 2.2 6.8 3v4.8c0 3.9-2.8 6.8-6.8 8-4-1.2-6.8-4.1-6.8-8V7.2l6.8-3z"
fill="currentColor"></path>
<path d="M10.9 14.9 8.2 12.2l1.2-1.2 1.5 1.5 3.7-3.7 1.2 1.2-4.9 4.9z" fill="currentColor"></path>
</svg>
</span>
<a class="pb-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<div class="pb-brand-side">
<div class="pb-brand-tools">
<span class="pb-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<!-- 专业院系子站切换(《院系子站方案》):顶层站取其子站、院系子站取兄弟站(含自身);
无子站整块不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="pb-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="pb-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="pb-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="pb-colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
<nav class="pb-audience" aria-label="受众导航">
<span class="pb-audience-item">考生</span>
<span class="pb-audience-item">在校生</span>
<span class="pb-audience-item">教职工</span>
<span class="pb-audience-item">校友</span>
</nav>
</div>
<div class="pb-search-row">
<form class="pb-search" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="15" height="15" focusable="false" aria-hidden="true">
<path d="M10.5 3a7.5 7.5 0 0 1 5.9 12.1l4.3 4.3-1.4 1.4-4.3-4.3A7.5 7.5 0 1 1 10.5 3zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11z"
fill="currentColor"></path>
</svg>
<span>搜索</span>
</button>
</form>
<p class="pb-hotwords">
<span class="pb-hotwords-label">热门搜索:</span>
<a th:href="${url.search('招生', null)}">招生</a>
<a th:href="${url.search('就业', null)}">就业</a>
<a th:href="${url.search('科研', null)}">科研</a>
<a th:href="${url.search('通知', null)}">通知</a>
</p>
</div>
</div>
</div>
</div>
<!-- 白底吸顶主导航:外链栏目直接外跳;其余进栏目页;当前栏目(含祖先模块)主色下划线高亮;吸顶由 main.js 处理 -->
<nav class="pb-nav" id="pbNav" aria-label="栏目导航">
<div class="pb-nav-inner">
<button class="pb-nav-toggle" type="button" aria-label="展开菜单" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<ul class="pb-nav-list">
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="pb-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:主色面板 + 顶部亮线(.pb-sub 展开动效见 assets/css/main.css);当前项高亮(docs/11) -->
<ul class="pb-sub" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}"
th:classappend="${sub.id == currentCategoryId} ? 'active'">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</div>
</nav>
</header>
</html>
@@ -0,0 +1,229 @@
<!--
poly-blue(理工蓝)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
形态设计语言参考浙江理工大学官网:理工要闻双栏(左大图头条/右校园经纬密行)、通知公告日期方块、
媒体理工来源徽标、图片墙渐变遮罩、快捷通道图标宫格。
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background),
与回落模式 categoryBlocks 兼容;区块标题一律取 category.name(两种模式都有值)。
类名全 pb- 前缀,样式见本主题 assets/css/main.css。
-->
<!-- 形态一:双栏要闻(homeStyle=news_grid)——左 3 份大图文头条卡(16:9 封面+标题+摘要+日期来源元信息)、
右 2 份校园经纬紧凑行(圆点前缀+日期右对齐);区块背景可取站点 news_bg_image -->
<section class="pb-section pb-container" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="pb-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="pb-block pb-block--flow"
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
<div class="pb-block-head">
<h2 th:text="${block.category.name}">理工要闻</h2>
<a class="pb-more" th:href="${block.moreUrl}">更多 +</a>
</div>
<div class="pb-split" th:if="${lead != null}">
<div class="pb-news-lead">
<a th:href="${url.article(lead.id)}">
<span class="pb-news-cover" th:if="${lead.coverImage != null}">
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
</span>
<span class="pb-news-body">
<span class="pb-news-title" th:text="${lead.title}">头条标题</span>
<span class="pb-news-summary" th:if="${!#strings.isEmpty(lead.summary)}"
th:text="${lead.summary}">摘要</span>
<span class="pb-news-meta">
<span class="pb-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<span class="pb-src" th:if="${!#strings.isEmpty(lead.source)}"
th:text="${lead.source}">来源</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</div>
<ul class="pb-brief-list" th:if="${rest != null}">
<li class="pb-brief-item" th:each="item : ${rest}">
<i class="pb-brief-dot" aria-hidden="true"></i>
<a class="pb-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="pb-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/poly-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:默认卡(homeStyle 未设/未知)——整宽头条 + 行式列表 -->
<section class="pb-section pb-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="pb-block pb-block--flow">
<div class="pb-block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="pb-more" th:href="${block.moreUrl}">更多 +</a>
</div>
<div class="pb-feat" th:if="${!#lists.isEmpty(block.items)}"
th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="pb-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="pb-feat-body">
<span class="pb-feat-title" th:text="${feat.title}">头条标题</span>
<span class="pb-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="pb-news-meta">
<span class="pb-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<span class="pb-src" th:if="${!#strings.isEmpty(feat.source)}"
th:text="${feat.source}">来源</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</div>
<ul class="pb-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="pb-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<i class="pb-item-tick" aria-hidden="true"></i>
<a class="pb-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="pb-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/poly-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:日期方块行(homeStyle=compact)——46px「日大号+年月小号」方块(主色底白字)+ 标题行 -->
<section class="pb-section pb-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="pb-block pb-block--flow">
<div class="pb-block-head">
<h2 th:text="${block.category.name}">通知公告</h2>
<a class="pb-more" th:href="${block.moreUrl}">更多 +</a>
</div>
<ul class="pb-notice-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="pb-notice-item" th:each="item : ${block.items}">
<span class="pb-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="pb-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/poly-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标(活力橙小 tag)+ 双列密行 -->
<section class="pb-section pb-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="pb-block pb-block--flow">
<div class="pb-block-head">
<h2 th:text="${block.category.name}">媒体理工</h2>
<a class="pb-more" th:href="${block.moreUrl}">更多 +</a>
</div>
<ul class="pb-media-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="pb-media-item" th:each="item : ${block.items}">
<span class="pb-src pb-src--tag" th:if="${!#strings.isEmpty(item.source)}"
th:text="${item.source}">媒体来源</span>
<a class="pb-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="pb-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/poly-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,四列格 + 底部渐变遮罩标题 -->
<section class="pb-section pb-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="pb-block-head">
<h2 th:text="${block.category.name}">图片新闻</h2>
<a class="pb-more" th:href="${block.moreUrl}">更多 +</a>
</div>
<ul class="pb-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="pb-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="pb-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/poly-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="pb-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="pb-container pb-ticker-inner">
<span class="pb-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="pb-ticker-list">
<li th:each="item : ${block.items}">
<a class="pb-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery) -->
<section class="pb-section pb-container pb-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
th:if="${!#lists.isEmpty(block.items)}">
<div class="pb-gallery-head">
<div class="pb-block-head pb-gallery-title">
<h2 th:text="${block.title}">线上展厅</h2>
</div>
<p class="pb-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
Online Exhibition Hall
</p>
</div>
<ul class="pb-wall pb-wall--4">
<li class="pb-wall-item" th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
<span class="pb-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:快捷通道(block.linkGroups 由渲染端按 group 过滤)——图标宫格卡,主色描边 hover 填色 -->
<section class="pb-section pb-container" th:fragment="links(block)" aria-label="快捷通道">
<div class="pb-block pb-block--flow">
<div class="pb-block-head">
<h2 th:text="${block.title}">快捷通道</h2>
</div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="pb-channel-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
<ul class="pb-channels">
<li th:each="l : ${grp.links}">
<a class="pb-channel" th:href="${l.linkUrl}" target="_blank" rel="noopener">
<span class="pb-channel-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false">
<path d="M10.6 13.4a1 1 0 0 1 0-1.4l4.6-4.6H12a1 1 0 1 1 0-2h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V8.8l-4.6 4.6a1 1 0 0 1-1.4 0z" fill="currentColor"></path>
<path d="M5 5h4a1 1 0 1 0 0-2H4a1 1 0 0 0-1 1v5a1 1 0 1 0 2 0V5z" fill="currentColor"></path>
<path d="M4 14a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1z" fill="currentColor"></path>
</svg>
</span>
<span class="pb-channel-name" th:text="${l.name}">通道名</span>
</a>
</li>
</ul>
</th:block>
</div>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="pb-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="pb-empty-hint">内容发布后将在此展示</p>
</div>
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理"。
数据绑定与 default 同构,仅换皮。 -->
<main class="pb-page pb-container" id="main-content">
<div class="pb-page-card pb-svc-guide">
<nav class="pb-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<a th:href="${url.serviceList()}">服务中心</a>
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<span class="pb-crumb-current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
</nav>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="pb-empty pb-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="pb-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<article class="pb-svc-guide-body" th:if="${!svcUnavailable}">
<h1 class="pb-article-title" th:text="${item.title}">事项名称</h1>
<div class="pb-article-meta">
<span th:if="${!#strings.isEmpty(item.deptName)}"
th:text="${'办理部门:' + item.deptName}">办理部门</span>
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
<time th:if="${item.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
</div>
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
<div class="pb-svc-apply" th:if="${applyUrl != null}">
<a class="pb-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
<span class="pb-svc-apply-hint">进入服务大厅在线办理</span>
</div>
<p class="pb-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
<table class="pb-svc-info">
<caption class="skip-link">事项基本信息</caption>
<tbody>
<tr th:if="${!#strings.isEmpty(item.objectName)}">
<th scope="row">服务对象</th>
<td th:text="${item.objectName}">服务对象</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
<th scope="row">事项类别</th>
<td th:text="${item.categoryName}">事项类别</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
<th scope="row">办理地点</th>
<td th:text="${item.handlePlace}">办理地点</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
<th scope="row">办理时限</th>
<td th:text="${item.handleTimeLimit}">办理时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
<th scope="row">承诺时限</th>
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
<th scope="row">咨询电话</th>
<td th:text="${item.contactPhone}">咨询电话</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
<th scope="row">监督投诉</th>
<td th:text="${item.supervisePhone}">监督投诉电话</td>
</tr>
</tbody>
</table>
<section class="pb-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
<h2>办理流程</h2>
<div class="pb-article-content" th:utext="${item.processHtml}">办理流程</div>
</section>
<section class="pb-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
<h2>所需材料</h2>
<div class="pb-article-content" th:utext="${item.materialHtml}">所需材料</div>
</section>
<section class="pb-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
<h2>注意事项</h2>
<div class="pb-article-content" th:utext="${item.noticeHtml}">注意事项</div>
</section>
<section class="pb-svc-section pb-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
<h2>相关材料下载</h2>
<ul>
<li th:each="att : ${attachments}">
<a th:href="${url.upload(att.webUrl)}" th:download
th:text="${att.fileName}">材料文件</a>
<span class="pb-svc-att-size" th:if="${att.fileSize != null}"
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
</li>
</ul>
</section>
<nav class="pb-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
</nav>
</article>
<aside class="pb-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
<h2 class="pb-svc-related-title">相关服务</h2>
<ul class="pb-svc-related-list">
<li th:each="rel : ${relatedItems}">
<a th:href="${rel.url}">
<span class="pb-svc-related-name" th:text="${rel.title}">相关事项</span>
<span class="pb-svc-related-meta">
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
<span class="pb-tag"
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
</span>
</a>
</li>
</ul>
</aside>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="pb-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize);
图上左下角标题渐变遮罩(assets/css/main.css) -->
<section class="pb-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="pb-banner-slides">
<a class="pb-banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="pb-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="pb-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="pb-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="pb-banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——派生包必须自带区块,
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:each="block : ${layoutBlocks}">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染(数据绑定与 default 同构,仅换皮) -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 头条区:头条推荐(置顶优先) + 图片新闻(有封面文章) -->
<section class="pb-section pb-container" aria-label="头条与图片新闻">
<div class="pb-headline">
<div class="pb-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
<div class="pb-block-head">
<h2>头条推荐</h2>
</div>
<ul class="pb-headline-list">
<li class="pb-headline-lead" th:with="lead=${topArticles[0]}">
<a class="pb-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="pb-lead-meta">
<span class="pb-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</li>
<li th:each="item, stat : ${topArticles}" th:if="${stat.index > 0}">
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</div>
<div class="pb-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="pb-block-head">
<h2>图片新闻</h2>
</div>
<a class="pb-focus-lead" th:with="cover=${focusArticles[0]}"
th:href="${url.article(cover.id)}">
<span class="pb-focus-cover">
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
</span>
<span class="pb-focus-title" th:text="${cover.title}">图片新闻标题</span>
</a>
<ul class="pb-focus-rows">
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
<a th:href="${url.article(row.id)}">
<span class="pb-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
<span class="pb-row-title" th:text="${row.title}">图片新闻标题</span>
</a>
</li>
</ul>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8,D1-2) -->
<section class="pb-section pb-container" aria-label="栏目区块">
<div class="pb-blocks">
<div class="pb-block" th:each="block : ${categoryBlocks}">
<div class="pb-block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="pb-more" th:href="${block.moreUrl}">更多 +</a>
</div>
<ul class="pb-notice-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="pb-feat" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="pb-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="pb-feat-body">
<span class="pb-feat-title" th:text="${feat.title}">头条标题</span>
<span class="pb-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="pb-news-meta">
<span class="pb-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
<li class="pb-notice-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="pb-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="pb-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="pb-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="pb-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="pb-page pb-container" id="main-content">
<div class="pb-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="pb-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="pb-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="pb-side-nav-list">
<li class="pb-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="pb-page-card">
<nav class="pb-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="pb-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="pb-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<span class="pb-crumb-current">搜索结果</span>
</nav>
<h1 class="pb-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="pb-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="pb-article-list">
<!-- 有封面:图文行;无封面:日期方块行(46px 方块 + 标题,理工公告风) -->
<li th:each="item : ${listData.items}">
<div class="pb-article-row" th:if="${item.coverImage != null}">
<a class="pb-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="pb-item-body">
<div class="pb-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="pb-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="pb-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="pb-article-line" th:if="${item.coverImage == null}">
<span class="pb-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<div class="pb-line-main">
<a class="pb-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="pb-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
</div>
<div class="pb-line-side">
<span class="pb-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</li>
</ul>
<div class="pb-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="pb-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="pb-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pb-pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
<a th:classappend="${cur <= 1} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
th:aria-disabled="${cur <= 1}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:aria-label="${'第' + p + '页'}"
th:text="${p}">1</a>
<a th:classappend="${cur >= total} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
th:aria-disabled="${cur >= total}">下一页</a>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query(数据绑定与 default 同构,仅换皮) -->
<main class="pb-page pb-container" id="main-content">
<div class="pb-page-card pb-svc-page">
<nav class="pb-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<span class="pb-crumb-current">服务中心</span>
</nav>
<h1 class="pb-page-title">服务中心</h1>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="pb-empty pb-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="pb-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<div class="pb-svc-filters"
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
<div class="pb-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
<span class="pb-svc-filter-label">服务对象</span>
<div class="pb-svc-filter-options">
<a class="pb-chip" th:each="opt : ${serviceFilters.objects}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="pb-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
<span class="pb-svc-filter-label">事项类别</span>
<div class="pb-svc-filter-options">
<a class="pb-chip" th:each="opt : ${serviceFilters.categories}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="pb-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
<span class="pb-svc-filter-label">服务部门</span>
<div class="pb-svc-filter-options">
<a class="pb-chip" th:each="opt : ${serviceFilters.depts}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
</div>
<div class="pb-svc-toolbar" th:if="${!svcUnavailable}">
<form class="pb-svc-search" th:action="${url.serviceList()}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
<button type="submit">搜索</button>
</form>
<a class="pb-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
</div>
<ul class="pb-svc-list" th:if="${!svcUnavailable}">
<li th:each="it : ${serviceList.items}">
<a class="pb-svc-item" th:href="${url.serviceItem(it.id)}">
<span class="pb-svc-item-cover" th:if="${it.coverImage != null}">
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
</span>
<span class="pb-svc-item-body">
<span class="pb-svc-item-title">
<span class="pb-svc-item-name" th:text="${it.title}">事项名称</span>
<span class="pb-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
<span class="pb-tag"
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
</span>
<span class="pb-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
th:text="${it.summary}">简介</span>
<span class="pb-svc-item-meta">
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
<time th:if="${it.publishTime != null}"
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<div class="pb-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
<p class="pb-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
<p class="pb-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
</div>
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
<nav class="pb-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
<a th:each="p : ${serviceList.pageUrls}"
th:classappend="${p.active} ? 'active'"
th:href="${p.url}"
th:aria-label="${'第' + p.page + '页'}"
th:aria-current="${p.active} ? 'page'"
th:text="${p.page}">1</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="pb-page pb-container" id="main-content">
<div class="pb-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="pb-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="pb-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="pb-side-nav-list">
<li class="pb-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="pb-page-card">
<nav class="pb-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="pb-crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="pb-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="pb-crumb-current">正文</span>
</nav>
<article class="pb-article">
<h1 class="pb-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="pb-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="pb-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="pb-empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,29 @@
{
"specVersion": 1,
"name": "poly-blue",
"title": "理工蓝(poly-blue)",
"version": "1.0.0",
"author": "yunhai",
"description": "深宝蓝+活力橙的理工院校风(设计语言参考浙江理工大学官网)。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--pb-primary", "label": "主色", "type": "color", "default": "#0558a5" },
{ "key": "primaryDark", "cssVar": "--pb-primary-dark", "label": "主色加深", "type": "color", "default": "#124489" },
{ "key": "primaryLight", "cssVar": "--pb-primary-light","label": "主色提亮", "type": "color", "default": "#258cff" },
{ "key": "accent", "cssVar": "--pb-accent", "label": "活力橙强调", "type": "color", "default": "#ff7b00" },
{ "key": "secondary", "cssVar": "--pb-secondary", "label": "绿色小标签", "type": "color", "default": "#1c7b46" },
{ "key": "navBg", "cssVar": "--pb-nav-bg", "label": "主导航底色", "type": "color", "default": "#ffffff" },
{ "key": "footerBg", "cssVar": "--pb-footer-bg", "label": "页脚底色", "type": "color", "default": "#124489" },
{ "key": "text", "cssVar": "--pb-text", "label": "正文文字色", "type": "color", "default": "#2f3a47" },
{ "key": "textLight", "cssVar": "--pb-text-light", "label": "次要文字色", "type": "color", "default": "#708090" },
{ "key": "bg", "cssVar": "--pb-bg", "label": "页面底色", "type": "color", "default": "#f5f8fc" },
{ "key": "cardBg", "cssVar": "--pb-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--pb-border", "label": "边框色", "type": "color", "default": "#e2e8f2" },
{ "key": "headerStyle", "label": "头部样式", "type": "select", "default": "banner",
"options": [
{ "value": "banner", "label": "通栏横幅头图" },
{ "value": "plain", "label": "深色品牌行+白底吸顶" }
] }
]
}
@@ -135,6 +135,30 @@ class ThemeRegistryTest {
} }
} }
@Test
@DisplayName("高校官网风第二批: poly-blue/agri-forest/jade-normal 注册成功,主色与存量全部互异")
void universitySkinsRegisteredWithDistinctPrimary() {
String defaultPrimary = registry.get(ThemeRegistry.DEFAULT_THEME).param("primary").getDefaultValue();
String scholarPrimary = registry.get("scholar-blue").param("primary").getDefaultValue();
java.util.Map<String, String> seen = new java.util.LinkedHashMap<>();
for (String name : new String[] {"poly-blue", "agri-forest", "jade-normal"}) {
ThemeInfo info = registry.get(name);
assertThat(info).as("%s 皮肤包必须注册(清单+必备模板+截图齐全)", name).isNotNull();
assertThat(info.getTitle()).isNotBlank();
ThemeParam primary = info.param("primary");
assertThat(primary).as("%s 应声明 primary 参数", name).isNotNull();
assertThat(primary.getDefaultValue())
.as("%s 的 primary 不得与 default 相同", name).isNotEqualTo(defaultPrimary);
assertThat(primary.getDefaultValue())
.as("%s 的 primary 不得与 scholar-blue 相同(拷贝忘改哨兵)", name)
.isNotEqualTo(scholarPrimary);
seen.put(name, primary.getDefaultValue());
}
assertThat(seen.get("poly-blue")).isNotEqualTo(seen.get("agri-forest"));
assertThat(seen.get("poly-blue")).isNotEqualTo(seen.get("jade-normal"));
assertThat(seen.get("agri-forest")).isNotEqualTo(seen.get("jade-normal"));
}
@Test @Test
@DisplayName("default 包缺失时 fail-fast(方案 §5.1,启动失败防线)") @DisplayName("default 包缺失时 fail-fast(方案 §5.1,启动失败防线)")
void defaultMissingFailsFast() { void defaultMissingFailsFast() {
@@ -0,0 +1,200 @@
package com.yunhai.cms.theme;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 高校官网风皮肤契约·第二批(poly-blue / agri-forest / jade-normal,以 default 为基座派生)。
*
* 与 AcademicSkinContractTest 同构,钉住同一组渲染不变量(自带九段首页区块、
* home.html 按 homeStyle 分发到本包 fragment、中文防塌列、:root 契约变量别名、
* 背景带层序、预览图必须是本皮肤自有图)。配色断言按三包定位分档:
* poly-blue 主色蓝通道主导(参考浙江理工大学 #0558A5),agri-forest/jade-normal
* 主色绿通道主导(参考信阳农林学院 #1C6E19 / 信阳师范大学 #008D3F)——
* 粉/紫系已被用户否决(cherry-bloom/violet-iris 事故),此处锁死不回流。
*/
@DisplayName("高校官网风皮肤契约·第二批 - 自带首页区块 + 高校官网配色")
class UniversitySkinContractTest {
/** 皮肤名:结构类名前缀,两处必须一致(拷贝忘改前缀会让皮肤互相串样式)。 */
private static final String[] SKINS = {"poly-blue:pb", "agri-forest:af", "jade-normal:jn"};
private static String skin(String spec) {
return spec.substring(0, spec.indexOf(':'));
}
private static String prefix(String spec) {
return spec.substring(spec.indexOf(':') + 1);
}
private static String text(String classpath) {
try (InputStream in = UniversitySkinContractTest.class.getResourceAsStream(classpath)) {
assertThat(in).as("classpath 资源必须存在:%s", classpath).isNotNull();
return new String(readAll(in), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
private static byte[] readAll(InputStream in) throws IOException {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
private static String ruleOf(String css, String selector) {
Matcher m = Pattern.compile(Pattern.quote(selector) + "\\s*\\{([^}]*)\\}").matcher(css);
assertThat(m.find()).as("main.css 应含选择器 %s 的规则", selector).isTrue();
return m.group(1).replaceAll("\\s+", "");
}
/** 抽出所有含 marker 的开始标签,用于检查同一标签上的属性组合。 */
private static java.util.List<String> tagsContaining(String html, String marker) {
java.util.List<String> tags = new java.util.ArrayList<>();
Matcher m = Pattern.compile("<[a-zA-Z][^>]*>").matcher(html);
while (m.find()) {
if (m.group().contains(marker)) {
tags.add(m.group());
}
}
return tags;
}
@Test
@DisplayName("派生包自带首页区块 fragment:布局模式下区块由皮肤渲染,不落通用骨架")
void skinOwnsHomeBlockFragments() {
for (String spec : SKINS) {
String html = text("/templates/themes/" + skin(spec) + "/fragments/home-blocks.html");
for (String fragment : new String[] {"news_grid", "media_focus", "compact", "image_wall",
"card", "ticker", "gallery", "links", "empty"}) {
assertThat(html).as("%s 区块 fragment 必须自带", fragment)
.contains("th:fragment=\"" + fragment + "(block)\"");
}
// 空态:th:replace(优先级 100)先于 th:if(300),守卫写在同一标签上等于没写
for (String tag : tagsContaining(html, ":: empty(")) {
assertThat(tag).as("%s 的空态守卫必须在外层 th:block 上", skin(spec)).doesNotContain("th:if");
}
assertThat(html).contains("th:block th:if=\"${#lists.isEmpty(block.items)}\"");
}
}
@Test
@DisplayName("home.html 把布局区块分发到本皮肤 fragment,区块头带右置「更多」")
void homeDispatchesToOwnFragments() {
for (String spec : SKINS) {
String html = text("/templates/themes/" + skin(spec) + "/home.html");
assertThat(html).as("须按 layoutBlocks 分支渲染").contains("layoutBlocks != null");
for (String style : new String[] {"news_grid", "media_focus", "compact", "image_wall"}) {
assertThat(html).as("%s 形态 %s 应走本皮肤 fragment", skin(spec), style)
.contains("'/fragments/home-blocks' :: " + style + "(${block})");
}
assertThat(html).as("区块标题行须有右侧「更多」样式钩子").contains(prefix(spec) + "-more");
}
}
@Test
@DisplayName("中文行不被压塌:速递条单行可收缩、列表标题超长省略")
void rowsMustNotCollapseCjkText() {
for (String spec : SKINS) {
String css = text("/templates/themes/" + skin(spec) + "/assets/css/main.css");
String p = prefix(spec);
String list = ruleOf(css, "." + p + "-ticker-list");
assertThat(list).as("flex 列表须让出收缩权并锁单行").contains("min-width:0").contains("white-space:nowrap");
String item = ruleOf(css, "." + p + "-ticker-item");
assertThat(item).as("条目不被压缩且超长省略").contains("flex:none").contains("text-overflow:ellipsis");
String title = ruleOf(css, "." + p + "-block-item-title");
assertThat(title).contains("min-width:0").contains("overflow:hidden").contains("text-overflow:ellipsis");
}
}
@Test
@DisplayName("皮肤变量别名到主题契约变量,供 common 兜底骨架继承")
void skinAliasesContractVars() {
for (String spec : SKINS) {
String css = text("/templates/themes/" + skin(spec) + "/assets/css/main.css");
String p = prefix(spec);
String root = ruleOf(css, ":root");
assertThat(root).contains("--primary:var(--" + p + "-primary)")
.contains("--text:var(--" + p + "-text)")
.contains("--card-bg:var(--" + p + "-card-bg)")
.contains("--border:var(--" + p + "-border)");
}
}
@Test
@DisplayName("新闻背景带画在卡片之下:绝对定位层必须压 z-index,卡片抬起来")
void newsBandMustPaintBehindCard() {
for (String spec : SKINS) {
String css = text("/templates/themes/" + skin(spec) + "/assets/css/main.css");
String p = prefix(spec);
assertThat(ruleOf(css, "." + p + "-sec-bg")).as("%s 背景带须压到卡片之下", p).contains("z-index:0");
assertThat(ruleOf(css, "." + p + "-sec-bg + ." + p + "-block"))
.as("%s 卡片须抬到背景带之上,否则新闻带图片盖住正文", p).contains("z-index:1");
}
}
@Test
@DisplayName("皮肤预览图必须是本皮肤自有图,不得沿用基座 default 的截图")
void screenshotMustBeOwnCapture() {
byte[] base = bytes("/templates/themes/default/assets/screenshot.png");
for (String spec : SKINS) {
String path = "/templates/themes/" + skin(spec) + "/assets/screenshot.png";
byte[] shot = bytes(path);
assertThat(shot).as("%s 的预览图与基座雷同,等于没换肤", skin(spec)).isNotEqualTo(base);
assertThat(shot.length).as("%s 预览图不能是空文件", skin(spec)).isGreaterThan(20 * 1024);
}
}
private static byte[] bytes(String classpath) {
try (InputStream in = UniversitySkinContractTest.class.getResourceAsStream(classpath)) {
assertThat(in).as("classpath 资源必须存在:%s", classpath).isNotNull();
return readAll(in);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@Test
@DisplayName("配色必须是高校官网主色:poly-blue 蓝通道主导、agri-forest/jade-normal 绿通道主导,粉紫禁入")
void primaryMustBeUniversityHue() throws IOException {
for (String spec : SKINS) {
JsonNode root = new ObjectMapper()
.readTree(text("/templates/themes/" + skin(spec) + "/theme.json"));
assertThat(root.path("name").asText()).as("name 必须等于目录名").isEqualTo(skin(spec));
String primary = null;
for (JsonNode p : root.path("params")) {
if ("primary".equals(p.path("key").asText())) {
primary = p.path("default").asText();
}
}
assertThat(primary).as("%s theme.json 须声明 primary 颜色参数", skin(spec)).isNotNull();
Matcher m = Pattern.compile("#([0-9a-fA-F]{6})").matcher(primary);
assertThat(m.matches()).as("primary 需为 6 位 hex,实际 %s", primary).isTrue();
int rgb = Integer.parseInt(m.group(1), 16);
int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
double lightness = (Math.max(r, Math.max(g, b)) + Math.min(r, Math.min(g, b))) / 255d / 2d;
if ("poly-blue".equals(skin(spec))) {
assertThat(b).as("理工蓝主色须蓝通道主导,实际 %s", primary).isGreaterThan(r).isGreaterThan(g);
} else {
assertThat(g).as("%s 主色须绿通道主导(农林/师范绿),实际 %s", skin(spec), primary)
.isGreaterThan(r).isGreaterThan(b);
}
assertThat(lightness).as("官网主色应为沉稳深色而非粉彩,实际 %s", primary)
.isBetween(0.18d, 0.52d);
}
}
}