feat(theme)!: 下线粉/紫两包,改以学院蓝为基座交付 academic-navy/campus-azure 两套高校官网风皮肤
build / build-push (push) Successful in 51s

线上 10027 首页配了 home_layout 后走 themes/common 通用骨架,派生皮肤只换了页头页脚,
正文仍是骨架内置蓝灰卡(外加 cherry-bloom 的粉紫配色),用户判定"完全不能用"。

- 新增 academic-navy(学府藏蓝,an-)/campus-azure(青蓝校园,cz-):以 scholar-blue 为基座,
  各自自带 fragments/home-blocks.html 与 home.html 分发,news_grid/media_focus/compact/
  image_wall/card/ticker/gallery/links 八种区块形态全部由皮肤渲染,未知类型才回落通用骨架
- 两套皮肤 :root 把私有变量别名到 --primary/--text/--card-bg/--border/--radius 契约变量;
  中文行锁 nowrap+min-width:0+ellipsis,grid 轨道用 minmax(0,1fr) 防窄视口压塌
- 新闻背景带 .an-sec-bg/.cz-sec-bg 压 z-index:0、卡片抬到 1:绝对定位层默认画在普通流之上,
  不抬起会整张卡片泛成底图色(无头截图实测)
- common 骨架空态守卫改包 th:block(th:replace 优先级 100 先于 th:if 300)
- 删除 cherry-bloom/violet-iris;sql/upgrade_theme_academic_skins.sql 幂等改指存量站点
  (站点缓存 CacheNames.SITE TTL 30 分钟,改库后需重启或在管理端重存站点)
- 测试:AcademicSkinContractTest(区块自带/分发/CJK 行/变量别名/主色必须学术蓝/背景带层级/
  预览图不得沿用基座)、HomeBlocksCssContractTest、PortalHomeLayoutIT 端到端断言零 plb- 输出;
  全量 383 项通过
This commit is contained in:
chenxing
2026-09-19 20:21:30 +08:00
parent 52433f06a2
commit d2df12b91e
65 changed files with 5498 additions and 2118 deletions
+3 -3
View File
@@ -21,10 +21,10 @@ WHERE code = 'demo';
UPDATE cms_banner SET status = 0 WHERE id = 5;
-- 2) 院系子站(demo 的子站:header 出"专业院系"下拉;不配 public_base_url=按请求派生)
-- 主题应用:jdx→cherry-bloom(樱语粉韵)、xxgc→violet-iris(鸢尾紫韵)——两套新皮肤随种子落地可视验收
-- 主题应用:jdx→academic-navy(学府藏蓝)、xxgc→campus-azure(青蓝校园)——两套高校官网风皮肤随种子落地可视验收
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'cherry-bloom', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'violet-iris', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'academic-navy', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'campus-azure', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES
(61, 6, 0, '学院概况', 2, NULL, '<p>机电学院(视觉精修种子)。</p>', 1, 1, 'cmssuper', NOW()),
(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
+21
View File
@@ -0,0 +1,21 @@
-- ============================================================
-- 皮肤升级脚本:粉/紫两包下线 → 高校官网风两包接管(幂等,存量库执行一次即可)
-- 背景:cherry-bloom(樱语粉韵)/ violet-iris(鸢尾紫韵)不符合高校门户视觉,已从模板库删除;
-- 存量站点若仍指向这两个主题,ThemeRegistry 匹配不到会静默回落 default(皮肤全丢),
-- 故必须把 theme 列改到新包:academic-navy(学府藏蓝)/ campus-azure(青蓝校园)。
-- 目标库:存量 bsch_cms(生产/预发);干净环境无需本脚本
-- 顺序:备份查询 → 回填 → 校验
-- ============================================================
-- 1) 先看受影响站点(人工确认后再执行第 2 步)
SELECT id, path, name, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
-- 2) 回填:粉韵→学府藏蓝,紫韵→青蓝校园(仅命中旧值,重复执行无副作用)
UPDATE cms_site SET theme = 'academic-navy' WHERE theme = 'cherry-bloom';
UPDATE cms_site SET theme = 'campus-azure' WHERE theme = 'violet-iris';
-- 3) 校验(应返回 0 行)
SELECT id, path, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
-- 备注:站点快照走 Caffeine CacheNames.SITE,TTL 30 分钟(±60s 抖动,见 CacheConfig),
-- 实测改库后 400s 门户仍是旧皮肤——改库后请重启服务,或在管理端重新保存一次站点(换肤会主动清缓存)。
@@ -0,0 +1,33 @@
# academic-navy(学府藏蓝)
高校官网风皮肤包:以 `scholar-blue` 为基座派生,配色与首页区块形态按综合类大学官网(浙江理工大学一类)重做。
配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染——派生包不自带区块形态时,
正文会落到 `themes/common` 的通用骨架,换肤就只剩页头页脚(线上事故根因,见《审计验收清单-20260919》)。
## 风格定位
- 主色藏蓝 `#0b4a8f` + 加深 `#063161` + 点缀金 `#b8934a`(标题左竖条与居中线脚走金线,区别于基座的全蓝语言)。
- 圆角收紧到 4/8px、页脚 `#0a2b4d`,行高压到 40px 一档,整体比基座更「机关报」而非「校园活动页」。
- 全部 11 个颜色参数可在站点 theme_params 微调(`primary/primaryDark/primaryLight/accent/navBg/footerBg/text/textLight/bg/cardBg/border`)。
## 首页区块形态(布局模式与回落模式同构)
| homeStyle | 呈现 | 类名 |
| -- | -- | -- |
| `news_grid` | 双栏要闻:左 3 份图文头条(16:7 封面 + 摘要 + 元信息)、右 2 份紧凑行;区块背景可取站点 `news_bg_image` | `an-split` / `an-feat-*` |
| `compact` | 日期方块行:46px 见方的「日 + 年月」方块,hover 反白 | `an-date-chip` |
| `media_focus` | 媒体聚焦:来源徽标 + 双列密行 | `an-src` / `an-block-list--two` |
| `image_wall` | 四列图片墙:封面 + 底部渐变遮罩标题,hover 抬图 | `an-wall` |
| (空/未知) | 整宽区块卡:头条 + 行式列表 | `an-block--flow` |
| `ticker` | 藏蓝要闻速递横条(单行不换行、超长省略,中文不塌列) | `an-ticker*` |
| `gallery` | 线上展厅:居中双语文区头 + 四列图墙 | `an-gallery` |
| `links` | 友情链接分组(胶囊标签行) | `an-links` |
## 契约要点
- 结构类前缀 `an-`,CSS 变量前缀 `--an-`;`:root` 把 `--an-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
- 回归防线:`AcademicSkinContractTest`(自带区块 fragment + 中文不塌列 + 契约变量别名 + 主色必须学术蓝)、
`PortalHomeLayoutIT#academicNavy_layoutUsesOwnFragments`(布局模式不落通用骨架)、`ThemeContractIT`(7 条路由)。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<rect width="96" height="96" rx="20" fill="#0a5aa8"/>
<path d="M48 16l8.6 17.5 19.3 2.8-14 13.6 3.3 19.2L48 60.1 30.8 69.1l3.3-19.2-14-13.6 19.3-2.8L48 16z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

@@ -0,0 +1,190 @@
/* academic-navy 主题脚本:顶栏日期 + 轮播(圆点/箭头/触摸) + 导航吸顶反蓝 + 移动端抽屉与二级手风琴。
与 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('anNav');
var DESKTOP_MIN = 769;
var navBaseTop = 0;
var holder = null;
function measureNavTop() {
unlockNav();
navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset;
}
function lockNav() {
if (!nav.classList.contains('an-nav--fixed')) {
holder = document.createElement('div');
holder.className = 'an-nav-holder';
holder.style.height = nav.offsetHeight + 'px';
nav.parentNode.insertBefore(holder, nav);
nav.classList.add('an-nav--fixed');
} else if (holder) {
holder.style.height = nav.offsetHeight + 'px';
}
}
function unlockNav() {
nav.classList.remove('an-nav--fixed');
if (holder && holder.parentNode) {
holder.parentNode.removeChild(holder);
}
holder = null;
}
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('.an-nav-toggle');
var navList = document.querySelector('.an-nav-list');
if (navToggle && navList) {
navToggle.addEventListener('click', function () {
var open = navList.classList.toggle('an-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('.an-sub');
if (!sub) { return; }
if (!li.classList.contains('an-sub-open')) {
e.preventDefault();
li.classList.add('an-sub-open');
}
});
}
/* ---------- 首页轮播 ---------- */
var root = document.querySelector('.an-banner');
var slides = document.querySelectorAll('.an-banner-slide');
var dots = document.querySelectorAll('.an-banner-dots button');
var prevBtn = document.querySelector('.an-banner-arrow.prev');
var nextBtn = document.querySelector('.an-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: 130 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="an-page an-container" id="main-content">
<div class="an-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="an-side-nav-list">
<li class="an-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="an-page-card">
<nav class="an-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="an-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="an-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="an-crumb-current">正文</span>
</nav>
<article class="an-article">
<h1 class="an-article-title" th:text="${article.title}">文章标题</h1>
<div class="an-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="an-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="an-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="an-sib prev"
th:href="${url.article(prevArticle.id)}">
<span class="an-sib-label">上一篇</span>
<span class="an-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="an-sib-none prev" th:if="${prevArticle == null}">
<span class="an-sib-label">上一篇</span>
<span class="an-sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="an-sib next"
th:href="${url.article(nextArticle.id)}">
<span class="an-sib-label">下一篇</span>
<span class="an-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="an-sib-none next" th:if="${nextArticle == null}">
<span class="an-sib-label">下一篇</span>
<span class="an-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,59 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- academic-navy 公共页脚:深蓝渐变底 + 同心圆环纹理(CSS 绘制) + 三栏(品牌/联系备案/友链分组) + 底条校名。
数据绑定与 default 契约一致(linkGroups/contact/copyright/beian/logo/schName),仅换皮。 -->
<footer class="an-footer" th:fragment="footer">
<i class="an-ring an-ring--1" aria-hidden="true"></i>
<i class="an-ring an-ring--2" aria-hidden="true"></i>
<i class="an-ring an-ring--3" aria-hidden="true"></i>
<div class="an-footer-inner">
<!-- 品牌栏 -->
<div class="an-footer-brand">
<span class="an-footer-badge" th:if="${site.config['logo'] != null}">
<img class="an-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="an-footer-badge an-footer-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" 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="an-footer-site" th:text="${site.name}">站点名称</span>
</div>
<!-- 联系/版权/备案 -->
<div class="an-footer-info">
<p class="an-footer-line" th:if="${site.config['contact'] != null}">
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['contact']}">联系方式</span>
</p>
<p class="an-footer-line" th:if="${site.config['copyright'] != null}">
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['copyright']}">版权信息</span>
</p>
<p class="an-footer-line" th:if="${site.config['beian'] != null}">
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['beian']}">备案号</span>
</p>
</div>
<!-- 友情链接(分组渲染;无分组整栏不渲染) -->
<div class="an-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="an-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="an-group-name" th:text="${group.groupName}">分组</span>
<p class="an-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="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>
<div class="an-footer-bottom">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</footer>
</html>
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) -->
<!-- academic-navy 公共 head 片段:与 default 契约一致(pageTitle/seo/canonical/og/themeVarsCss/favicon 兜底) -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -8,16 +8,13 @@
<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>
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- academic-navy 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 -->
<header class="an-header" th:fragment="header">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<!-- 顶栏 -->
<div class="an-topbar">
<div class="an-topbar-inner">
<span class="an-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="an-topbar-side" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<div class="an-colleges">
<span class="an-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="an-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="an-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="an-brandbar">
<div class="an-brandbar-inner">
<div class="an-brand">
<span class="an-brand-badge" th:if="${site.config['logo'] != null}">
<img class="an-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="an-brand-badge an-brand-badge--plain" 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>
<a class="an-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="an-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="16" height="16" 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>
</button>
</form>
</div>
</div>
<!-- 主导航:白底深蓝字;下拉为主蓝半透明面板(.an-sub 展开动效见 main.css);吸顶反蓝由 main.js 处理 -->
<nav class="an-nav" id="anNav" aria-label="栏目导航">
<div class="an-nav-inner">
<button class="an-nav-toggle" type="button" aria-label="展开菜单" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<ul class="an-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="an-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:主蓝半透明面板,顶部亮线,项 hover 加深+左亮条 -->
<ul class="an-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,205 @@
<!--
academic-navy(学府藏蓝)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
结构复用主题 main.css 第 7/9 节既有语汇(an-block / an-block-head--bar / an-block-item / an-date-chip /
an-feat-* / an-top-badge / an-empty),仅布局模式特有的排布(双栏、速递条、图墙、链接行)另起第 16 节。
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background),
与回落模式 categoryBlocks 兼容;区块标题一律取 category.name(两种模式都有值)。
-->
<!-- 形态一:双栏要闻(homeStyle=news_grid)——左 3 份头条图文、右 2 份紧凑行(综合类高校官网 60/40) -->
<section class="an-section an-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="an-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="an-block an-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="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">校园要闻</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<div class="an-split" th:if="${lead != null}">
<div class="an-block-featured">
<a th:href="${url.article(lead.id)}">
<span class="an-feat-cover" th:if="${lead.coverImage != null}">
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
</span>
<span class="an-feat-title" th:text="${lead.title}">头条标题</span>
<span class="an-feat-summary" th:if="${!#strings.isEmpty(lead.summary)}"
th:text="${lead.summary}">摘要</span>
<span class="an-feat-meta">
<span class="an-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>
</a>
</div>
<ul class="an-block-list" th:if="${rest != null}">
<li class="an-block-item" th:each="item : ${rest}">
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="an-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/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:默认卡(homeStyle 未设/未知)——整宽头条 + 行式列表 -->
<section class="an-section an-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<div class="an-block-featured" th:if="${!#lists.isEmpty(block.items)}"
th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="an-feat-cover an-feat-cover--row" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="an-feat-title" th:text="${feat.title}">头条标题</span>
<span class="an-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
<span class="an-feat-meta">
<span class="an-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>
</a>
</div>
<ul class="an-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="an-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="an-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/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:日期方块列表(homeStyle=compact)——官网最典型的「方块日期 + 标题」行式 -->
<section class="an-section an-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">通知公告</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-block-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="an-block-item" th:each="item : ${block.items}">
<span class="an-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="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列行式 -->
<section class="an-section an-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">媒体聚焦</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-block-list an-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="an-block-item" th:each="item : ${block.items}">
<span class="an-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="an-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/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,四列格 -->
<section class="an-section an-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">图片新闻</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="an-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="an-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="an-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="an-container an-ticker-inner">
<span class="an-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="an-ticker-list">
<li th:each="item : ${block.items}">
<a class="an-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery) -->
<section class="an-section an-container an-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
th:if="${!#lists.isEmpty(block.items)}">
<div class="an-block-head">
<h2 th:text="${block.title}">线上展厅</h2>
<i class="an-block-tick" aria-hidden="true"></i>
</div>
<p class="an-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
Online Exhibition Hall
</p>
<ul class="an-wall an-wall--4">
<li class="an-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="an-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:友情链接分组(block.linkGroups 由渲染端按 group 过滤) -->
<section class="an-section an-container" th:fragment="links(block)" aria-label="友情链接">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.title}">友情链接</h2>
</div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="an-links-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
<ul class="an-links">
<li th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
</li>
</ul>
</th:block>
</div>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="an-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="an-empty-hint">内容发布后将在此展示</p>
</div>
@@ -0,0 +1,149 @@
<!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="an-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸见 theme.json bannerSize) -->
<section class="an-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="an-banner-slides">
<a class="an-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="an-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="an-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="an-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="an-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="an-section an-container" aria-label="头条与图片新闻">
<div class="an-headline">
<div class="an-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
<div class="an-block-head">
<h2>头条推荐</h2>
<i class="an-block-tick" aria-hidden="true"></i>
</div>
<ul class="an-headline-list">
<li class="an-headline-lead" th:with="lead=${topArticles[0]}">
<a class="an-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="an-lead-meta">
<span class="an-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="an-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="an-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="an-block-head">
<h2>图片新闻</h2>
<i class="an-block-tick" aria-hidden="true"></i>
</div>
<a class="an-focus-lead" th:with="cover=${focusArticles[0]}"
th:href="${url.article(cover.id)}">
<span class="an-focus-cover">
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
</span>
<span class="an-focus-title" th:text="${cover.title}">图片新闻标题</span>
</a>
<ul class="an-focus-rows">
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
<a th:href="${url.article(row.id)}">
<span class="an-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
<span class="an-row-title" th:text="${row.title}">图片新闻标题</span>
</a>
</li>
</ul>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8) -->
<section class="an-section an-container" aria-label="栏目区块">
<div class="an-blocks">
<div class="an-block" th:each="block : ${categoryBlocks}">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-block-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="an-block-featured" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="an-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="an-feat-body">
<span class="an-feat-title" th:text="${feat.title}">头条标题</span>
<span class="an-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="an-feat-meta">
<span class="an-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="an-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="an-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="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="an-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="an-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,105 @@
<!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="an-page an-container" id="main-content">
<div class="an-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="an-side-nav-list">
<li class="an-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="an-page-card">
<nav class="an-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="an-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="an-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="an-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="an-crumb-sep" aria-hidden="true">/</span>
<span class="an-crumb-current">搜索结果</span>
</nav>
<h1 class="an-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="an-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="an-article-list">
<!-- 有封面:图文行;无封面:行式(符号 + 标题 + 右侧日期) -->
<li th:each="item : ${listData.items}">
<div class="an-article-row" th:if="${item.coverImage != null}">
<a class="an-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="an-item-body">
<div class="an-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="an-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="an-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="an-article-line" th:if="${item.coverImage == null}">
<i class="an-line-tick" aria-hidden="true"></i>
<div class="an-line-main">
<a class="an-line-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="an-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
</div>
<div class="an-line-side">
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
</li>
</ul>
<div class="an-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="an-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="an-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="an-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,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="an-page an-container" id="main-content">
<div class="an-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="an-side-nav-list">
<li class="an-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="an-page-card">
<nav class="an-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="an-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="an-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<article class="an-article">
<h1 class="an-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="an-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="an-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="an-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,23 @@
{
"specVersion": 1,
"name": "academic-navy",
"title": "学府藏蓝(academic-navy)",
"version": "1.0.0",
"author": "yunhai",
"description": "学府藏蓝·高校官网门户:藏蓝顶栏+白底主导航+金线标题+日期方块列表+双栏要闻(左图右列)+深色三段页脚。基座 scholar-blue,配色与首页区块形态按综合类高校官网重做。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--an-primary", "label": "主色", "type": "color", "default": "#0b4a8f" },
{ "key": "primaryDark", "cssVar": "--an-primary-dark", "label": "主色加深", "type": "color", "default": "#063161" },
{ "key": "primaryLight", "cssVar": "--an-primary-light","label": "主色提亮", "type": "color", "default": "#1a6cb5" },
{ "key": "accent", "cssVar": "--an-accent", "label": "点缀金", "type": "color", "default": "#b8934a" },
{ "key": "navBg", "cssVar": "--an-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" },
{ "key": "footerBg", "cssVar": "--an-footer-bg", "label": "页脚底色", "type": "color", "default": "#0a2b4d" },
{ "key": "text", "cssVar": "--an-text", "label": "正文文字色", "type": "color", "default": "#2b3644" },
{ "key": "textLight", "cssVar": "--an-text-light", "label": "次要文字色", "type": "color", "default": "#6b7889" },
{ "key": "bg", "cssVar": "--an-bg", "label": "页面底色", "type": "color", "default": "#f1f4f9" },
{ "key": "cardBg", "cssVar": "--an-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--an-border", "label": "边框色", "type": "color", "default": "#dbe3ee" }
]
}
@@ -0,0 +1,33 @@
# campus-azure(青蓝校园)
高校官网风皮肤包:以 `scholar-blue` 为基座派生,配色与首页区块形态按师范/农林类院校官网(信阳师范学院、
信阳农林学院一类)重做。配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染,
不落 `themes/common` 通用骨架(否则换肤只剩页头页脚)。
## 风格定位
- 主色青蓝 `#0d6a9e` + 加深 `#084c73` + 点缀青绿 `#2b9c8f`(列表圆点、标题线脚、速递标签走青绿)。
- 圆角放宽到 6/12px、页面底 `#eef3f6`,比 academic-navy 更松更「校园」;区块头统一走居中双线 + 右置「更多」。
- 全部 11 个颜色参数可在站点 theme_params 微调(`primary/primaryDark/primaryLight/accent/navBg/footerBg/text/textLight/bg/cardBg/border`)。
## 首页区块形态(布局模式与回落模式同构)
| homeStyle | 呈现 | 类名 |
| -- | -- | -- |
| `news_grid` | 图文要闻:全宽封面头条(底部渐变遮罩标题 + 元信息)+ 双列圆点密行;区块背景可取站点 `news_bg_image` | `cz-hero*` / `cz-block-list--two` |
| `compact` | 圆点密行:青绿圆点 + 标题 + 右对齐完整日期 | `cz-item-bullet` / `cz-item-date` |
| `media_focus` | 媒体聚焦:胶囊来源徽标 + 双列密行 | `cz-src` |
| `image_wall` | 三列大格图墙:圆角卡 + 底部遮罩标题 | `cz-wall` |
| (空/未知) | 整宽区块卡:左 2 份图文头条 + 右 3 份圆点行 | `cz-block--flow` / `cz-split` |
| `ticker` | 青蓝要闻速递横条,标签为青绿胶囊(单行不换行、超长省略,中文不塌列) | `cz-ticker*` |
| `gallery` | 线上展厅:居中双语文区头 + 四列图墙 | `cz-gallery` |
| `links` | 友情链接分组(胶囊标签行) | `cz-links` |
## 契约要点
- 结构类前缀 `cz-`,CSS 变量前缀 `--cz-`;`:root` 把 `--cz-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
- 回归防线:`AcademicSkinContractTest`(自带区块 fragment + 中文不塌列 + 契约变量别名 + 主色必须学术蓝)、
`PortalHomeLayoutIT#campusAzure_fallbackAndAssets`(回落分支 + 资产通道)、`ThemeContractIT`(7 条路由)。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<rect width="96" height="96" rx="20" fill="#0a5aa8"/>
<path d="M48 16l8.6 17.5 19.3 2.8-14 13.6 3.3 19.2L48 60.1 30.8 69.1l3.3-19.2-14-13.6 19.3-2.8L48 16z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

@@ -0,0 +1,190 @@
/* campus-azure 主题脚本:顶栏日期 + 轮播(圆点/箭头/触摸) + 导航吸顶反蓝 + 移动端抽屉与二级手风琴。
与 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('czNav');
var DESKTOP_MIN = 769;
var navBaseTop = 0;
var holder = null;
function measureNavTop() {
unlockNav();
navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset;
}
function lockNav() {
if (!nav.classList.contains('cz-nav--fixed')) {
holder = document.createElement('div');
holder.className = 'cz-nav-holder';
holder.style.height = nav.offsetHeight + 'px';
nav.parentNode.insertBefore(holder, nav);
nav.classList.add('cz-nav--fixed');
} else if (holder) {
holder.style.height = nav.offsetHeight + 'px';
}
}
function unlockNav() {
nav.classList.remove('cz-nav--fixed');
if (holder && holder.parentNode) {
holder.parentNode.removeChild(holder);
}
holder = null;
}
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('.cz-nav-toggle');
var navList = document.querySelector('.cz-nav-list');
if (navToggle && navList) {
navToggle.addEventListener('click', function () {
var open = navList.classList.toggle('cz-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('.cz-sub');
if (!sub) { return; }
if (!li.classList.contains('cz-sub-open')) {
e.preventDefault();
li.classList.add('cz-sub-open');
}
});
}
/* ---------- 首页轮播 ---------- */
var root = document.querySelector('.cz-banner');
var slides = document.querySelectorAll('.cz-banner-slide');
var dots = document.querySelectorAll('.cz-banner-dots button');
var prevBtn = document.querySelector('.cz-banner-arrow.prev');
var nextBtn = document.querySelector('.cz-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: 126 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="cz-page cz-container" id="main-content">
<div class="cz-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="cz-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="cz-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="cz-side-nav-list">
<li class="cz-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="cz-page-card">
<nav class="cz-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="cz-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="cz-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="cz-crumb-current">正文</span>
</nav>
<article class="cz-article">
<h1 class="cz-article-title" th:text="${article.title}">文章标题</h1>
<div class="cz-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="cz-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="cz-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="cz-sib prev"
th:href="${url.article(prevArticle.id)}">
<span class="cz-sib-label">上一篇</span>
<span class="cz-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="cz-sib-none prev" th:if="${prevArticle == null}">
<span class="cz-sib-label">上一篇</span>
<span class="cz-sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="cz-sib next"
th:href="${url.article(nextArticle.id)}">
<span class="cz-sib-label">下一篇</span>
<span class="cz-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="cz-sib-none next" th:if="${nextArticle == null}">
<span class="cz-sib-label">下一篇</span>
<span class="cz-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,59 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- campus-azure 公共页脚:深蓝渐变底 + 同心圆环纹理(CSS 绘制) + 三栏(品牌/联系备案/友链分组) + 底条校名。
数据绑定与 default 契约一致(linkGroups/contact/copyright/beian/logo/schName),仅换皮。 -->
<footer class="cz-footer" th:fragment="footer">
<i class="cz-ring cz-ring--1" aria-hidden="true"></i>
<i class="cz-ring cz-ring--2" aria-hidden="true"></i>
<i class="cz-ring cz-ring--3" aria-hidden="true"></i>
<div class="cz-footer-inner">
<!-- 品牌栏 -->
<div class="cz-footer-brand">
<span class="cz-footer-badge" th:if="${site.config['logo'] != null}">
<img class="cz-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="cz-footer-badge cz-footer-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" 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="cz-footer-site" th:text="${site.name}">站点名称</span>
</div>
<!-- 联系/版权/备案 -->
<div class="cz-footer-info">
<p class="cz-footer-line" th:if="${site.config['contact'] != null}">
<i class="cz-dot" aria-hidden="true"></i><span th:text="${site.config['contact']}">联系方式</span>
</p>
<p class="cz-footer-line" th:if="${site.config['copyright'] != null}">
<i class="cz-dot" aria-hidden="true"></i><span th:text="${site.config['copyright']}">版权信息</span>
</p>
<p class="cz-footer-line" th:if="${site.config['beian'] != null}">
<i class="cz-dot" aria-hidden="true"></i><span th:text="${site.config['beian']}">备案号</span>
</p>
</div>
<!-- 友情链接(分组渲染;无分组整栏不渲染) -->
<div class="cz-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="cz-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="cz-group-name" th:text="${group.groupName}">分组</span>
<p class="cz-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="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>
<div class="cz-footer-bottom">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</footer>
</html>
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) -->
<!-- campus-azure 公共 head 片段:与 default 契约一致(pageTitle/seo/canonical/og/themeVarsCss/favicon 兜底) -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -8,16 +8,13 @@
<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>
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- campus-azure 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 -->
<header class="cz-header" th:fragment="header">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<!-- 顶栏 -->
<div class="cz-topbar">
<div class="cz-topbar-inner">
<span class="cz-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="cz-topbar-side" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<div class="cz-colleges">
<span class="cz-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="cz-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="cz-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="cz-brandbar">
<div class="cz-brandbar-inner">
<div class="cz-brand">
<span class="cz-brand-badge" th:if="${site.config['logo'] != null}">
<img class="cz-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="cz-brand-badge cz-brand-badge--plain" 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>
<a class="cz-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="cz-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="16" height="16" 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>
</button>
</form>
</div>
</div>
<!-- 主导航:白底深蓝字;下拉为主蓝半透明面板(.cz-sub 展开动效见 main.css);吸顶反蓝由 main.js 处理 -->
<nav class="cz-nav" id="czNav" aria-label="栏目导航">
<div class="cz-nav-inner">
<button class="cz-nav-toggle" type="button" aria-label="展开菜单" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<ul class="cz-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="cz-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:主蓝半透明面板,顶部亮线,项 hover 加深+左亮条 -->
<ul class="cz-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,209 @@
<!--
campus-azure(青蓝校园)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
结构复用主题 main.css 第 7/9 节既有语汇(cz-block / cz-block-head / cz-block-item / cz-feat-* /
cz-top-badge / cz-empty),布局模式特有的排布(封面头条、圆点行、图墙、速递条、链接行)另起第 16 节。
与 academic-navy 的分野:区块头用居中双线(师范/农林官网语言)、要闻用全宽封面头条、列表用圆点密行。
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background)。
-->
<!-- 形态一:图文要闻(homeStyle=news_grid)——全宽封面头条(遮罩标题)+ 双列密行 -->
<section class="cz-section cz-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="cz-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="cz-block cz-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="cz-block-head">
<h2 th:text="${block.category.name}">校园要闻</h2>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<a class="cz-hero" th:if="${lead != null}" th:href="${url.article(lead.id)}">
<img th:if="${lead.coverImage != null}" th:src="${url.upload(lead.coverImage)}"
th:alt="${lead.title}" loading="lazy">
<span class="cz-hero-plain" th:if="${lead.coverImage == null}" aria-hidden="true"></span>
<span class="cz-hero-mask">
<span class="cz-hero-title" th:text="${lead.title}">头条标题</span>
<span class="cz-hero-meta">
<span class="cz-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>
<ul class="cz-block-list cz-block-list--two" th:if="${rest != null}">
<li class="cz-block-item" th:each="item : ${rest}">
<i class="cz-item-bullet" aria-hidden="true"></i>
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="cz-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/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:默认卡(homeStyle 未设/未知)——左图文头条 + 右圆点密行 -->
<section class="cz-section cz-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="cz-block cz-block--flow">
<div class="cz-block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<div class="cz-split" th:if="${!#lists.isEmpty(block.items)}"
th:with="feat=${block.items[0]}, rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
<div class="cz-block-featured">
<a th:href="${url.article(feat.id)}">
<span class="cz-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="cz-feat-title" th:text="${feat.title}">头条标题</span>
<span class="cz-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
<span class="cz-feat-meta">
<span class="cz-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>
</a>
</div>
<ul class="cz-block-list" th:if="${rest != null}">
<li class="cz-block-item" th:each="item : ${rest}">
<i class="cz-item-bullet" aria-hidden="true"></i>
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="cz-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/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:圆点密行(homeStyle=compact)——官网最密的「圆点 + 标题 + 右日期」单行 -->
<section class="cz-section cz-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="cz-block cz-block--flow">
<div class="cz-block-head">
<h2 th:text="${block.category.name}">通知公告</h2>
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<ul class="cz-block-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="cz-block-item" th:each="item : ${block.items}">
<i class="cz-item-bullet" aria-hidden="true"></i>
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="cz-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/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列密行 -->
<section class="cz-section cz-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="cz-block cz-block--flow">
<div class="cz-block-head">
<h2 th:text="${block.category.name}">媒体聚焦</h2>
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<ul class="cz-block-list cz-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="cz-block-item" th:each="item : ${block.items}">
<span class="cz-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="cz-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/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,三列大格 -->
<section class="cz-section cz-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="cz-block-head">
<h2 th:text="${block.category.name}">图片新闻</h2>
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<ul class="cz-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="cz-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="cz-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="cz-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="cz-container cz-ticker-inner">
<span class="cz-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="cz-ticker-list">
<li th:each="item : ${block.items}">
<a class="cz-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery) -->
<section class="cz-section cz-container cz-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
th:if="${!#lists.isEmpty(block.items)}">
<div class="cz-block-head">
<h2 th:text="${block.title}">线上展厅</h2>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<p class="cz-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
Online Exhibition Hall
</p>
<ul class="cz-wall cz-wall--4">
<li class="cz-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="cz-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:友情链接分组(block.linkGroups 由渲染端按 group 过滤) -->
<section class="cz-section cz-container" th:fragment="links(block)" aria-label="友情链接">
<div class="cz-block cz-block--flow">
<div class="cz-block-head">
<h2 th:text="${block.title}">友情链接</h2>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="cz-links-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
<ul class="cz-links">
<li th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
</li>
</ul>
</th:block>
</div>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="cz-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="cz-empty-hint">内容发布后将在此展示</p>
</div>
@@ -0,0 +1,149 @@
<!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="cz-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸见 theme.json bannerSize) -->
<section class="cz-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="cz-banner-slides">
<a class="cz-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="cz-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="cz-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="cz-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="cz-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="cz-section cz-container" aria-label="头条与图片新闻">
<div class="cz-headline">
<div class="cz-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
<div class="cz-block-head">
<h2>头条推荐</h2>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<ul class="cz-headline-list">
<li class="cz-headline-lead" th:with="lead=${topArticles[0]}">
<a class="cz-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="cz-lead-meta">
<span class="cz-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="cz-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="cz-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="cz-block-head">
<h2>图片新闻</h2>
<i class="cz-block-tick" aria-hidden="true"></i>
</div>
<a class="cz-focus-lead" th:with="cover=${focusArticles[0]}"
th:href="${url.article(cover.id)}">
<span class="cz-focus-cover">
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
</span>
<span class="cz-focus-title" th:text="${cover.title}">图片新闻标题</span>
</a>
<ul class="cz-focus-rows">
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
<a th:href="${url.article(row.id)}">
<span class="cz-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
<span class="cz-row-title" th:text="${row.title}">图片新闻标题</span>
</a>
</li>
</ul>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8) -->
<section class="cz-section cz-container" aria-label="栏目区块">
<div class="cz-blocks">
<div class="cz-block" th:each="block : ${categoryBlocks}">
<div class="cz-block-head cz-block-head--bar">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="cz-block-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="cz-block-featured" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="cz-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="cz-feat-body">
<span class="cz-feat-title" th:text="${feat.title}">头条标题</span>
<span class="cz-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="cz-feat-meta">
<span class="cz-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="cz-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="cz-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="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="cz-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="cz-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,105 @@
<!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="cz-page cz-container" id="main-content">
<div class="cz-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="cz-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="cz-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="cz-side-nav-list">
<li class="cz-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="cz-page-card">
<nav class="cz-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="cz-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="cz-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="cz-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="cz-crumb-sep" aria-hidden="true">/</span>
<span class="cz-crumb-current">搜索结果</span>
</nav>
<h1 class="cz-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="cz-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="cz-article-list">
<!-- 有封面:图文行;无封面:行式(符号 + 标题 + 右侧日期) -->
<li th:each="item : ${listData.items}">
<div class="cz-article-row" th:if="${item.coverImage != null}">
<a class="cz-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="cz-item-body">
<div class="cz-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="cz-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="cz-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="cz-article-line" th:if="${item.coverImage == null}">
<i class="cz-line-tick" aria-hidden="true"></i>
<div class="cz-line-main">
<a class="cz-line-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="cz-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
</div>
<div class="cz-line-side">
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
</li>
</ul>
<div class="cz-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="cz-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="cz-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="cz-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,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="cz-page cz-container" id="main-content">
<div class="cz-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="cz-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="cz-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="cz-side-nav-list">
<li class="cz-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="cz-page-card">
<nav class="cz-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="cz-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="cz-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<article class="cz-article">
<h1 class="cz-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="cz-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="cz-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="cz-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,23 @@
{
"specVersion": 1,
"name": "campus-azure",
"title": "青蓝校园(campus-azure)",
"version": "1.0.0",
"author": "yunhai",
"description": "青蓝校园·师范/农林类官网门户:青蓝顶栏+白底主导航+青绿描边标题+大日期数字列表+图文左列右栏+圆角卡片区块+深色页脚带二维码位。基座 scholar-blue,配色与首页区块形态按师范/农林类高校官网重做。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--cz-primary", "label": "主色", "type": "color", "default": "#0d6a9e" },
{ "key": "primaryDark", "cssVar": "--cz-primary-dark", "label": "主色加深", "type": "color", "default": "#084c73" },
{ "key": "primaryLight", "cssVar": "--cz-primary-light","label": "主色提亮", "type": "color", "default": "#1d87bd" },
{ "key": "accent", "cssVar": "--cz-accent", "label": "点缀青绿", "type": "color", "default": "#2b9c8f" },
{ "key": "navBg", "cssVar": "--cz-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" },
{ "key": "footerBg", "cssVar": "--cz-footer-bg", "label": "页脚底色", "type": "color", "default": "#0b3348" },
{ "key": "text", "cssVar": "--cz-text", "label": "正文文字色", "type": "color", "default": "#303c46" },
{ "key": "textLight", "cssVar": "--cz-text-light", "label": "次要文字色", "type": "color", "default": "#6e7d88" },
{ "key": "bg", "cssVar": "--cz-bg", "label": "页面底色", "type": "color", "default": "#eef3f6" },
{ "key": "cardBg", "cssVar": "--cz-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--cz-border", "label": "边框色", "type": "color", "default": "#d9e5ec" }
]
}
@@ -1,35 +0,0 @@
# cherry-bloom 皮肤包
由 mint-breeze 派生的温柔樱粉风皮肤(docs/10 契约 §5 工作流)。**升级随版本发布,不在站点间复制目录**。
## 风格定位
温柔、甜美,适合幼儿园与小学门户:樱花粉品牌色、大圆角 16px、卡片纯白+暖粉柔和阴影、细线顶栏+居中 logo(桌面吸顶,移动端取消)、列表卡片网格(一行 3 张)、花瓣式圆点装饰与暖白留白;无纹理、无通栏大图、无复杂动效。
## 数据契约(模板可取用的 model 属性全集,契约 §3)
| 页面 | 属性 |
| --- | --- |
| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/cherry-bloom`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) |
| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) |
| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) |
| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` |
| single | `category`(含 `content`)、`currentCategoryId` |
原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。
## 轮播图尺寸约定(bannerSize)
1920x560(管理端上传端按此引导裁剪,D2 决策点)。
## 可调参数
见 `theme.json` 的 `params`:
- **color**(进 CSS 变量):`primary` 主色 / `primaryDark` 主色加深(即强调色,用于品牌文字与悬停加深)/ `primaryLight` 主色提亮 / `bg` 页面底色 / `cardBg` 卡片底色 / `border` 边框色 / `text` 正文文字 / `textLight` 次要文字。
- **select**:`listLayout` = `grid`(默认,卡片网格一行 3 张)或 `row`(传统行列表)。
换色建议:主色系三值同步调整(primary / primaryDark / primaryLight),bg 与 border、primaryLight 保持同族浅色调,避免底色与卡片对比失衡。
优先级:`custom_css` > `themeVarsCss` > `main.css`。
@@ -1,616 +0,0 @@
/* ============================================================
* cherry-bloom 皮肤包样式(mint-breeze 派生,docs/10 皮肤包契约 §5)
* 温柔樱粉风:樱花粉品牌色 + 大圆角 16px + 柔和暖粉阴影 + 卡片网格列表
* + 花瓣式圆点装饰与暖白留白;无纹理、无通栏大图、无复杂动效。
* 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪)。
* ============================================================ */
:root {
--primary: #DB5A8B;
--primary-dark: #B23A68;
--primary-light: #FDECF3;
--text: #43333B;
--text-light: #9C828C;
--bg: #FFFAFB;
--card-bg: #FFFFFF;
--border: #F7DDE7;
--radius: 16px;
--radius-sm: 10px;
--radius-pill: 999px;
--shadow-soft: 0 6px 18px rgba(178, 58, 104, .08);
--shadow-hover: 0 12px 28px rgba(178, 58, 104, .14);
--max-width: 1200px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--bg);
}
a { color: inherit; text-decoration: none; transition: color .15s ease; }
a:hover { color: var(--primary); }
img { max-width: 100%; }
time, .item-meta { color: var(--text-light); font-size: 13px; }
.empty { color: var(--text-light); text-align: center; padding: 32px 0; }
/* ===== 头部:细线顶栏 + 居中品牌行 + 白底导航(桌面吸顶) ===== */
.site-header {
position: sticky;
top: 0;
z-index: 90;
background: var(--card-bg);
box-shadow: var(--shadow-soft);
}
/* 细线顶栏:院系切换在左、站内搜索在右;院系未配置时搜索仍靠右 */
.top-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 7px max(16px, calc((100% - var(--max-width)) / 2 + 16px));
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.top-bar .site-colleges { margin-right: auto; }
/* 居中品牌行:两侧几何圆点装饰(纯 CSS,无纹理) */
.brand-row {
position: relative;
display: flex;
justify-content: center;
padding: 18px 16px 14px;
}
.brand-row::before, .brand-row::after {
content: "";
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 46px;
height: 10px;
opacity: .45;
background-image:
radial-gradient(circle, var(--primary) 2.2px, transparent 2.8px),
radial-gradient(circle, var(--primary) 1.6px, transparent 2.2px),
radial-gradient(circle, var(--primary) 1.1px, transparent 1.7px);
background-repeat: no-repeat;
background-position: left center, center center, right center;
}
.brand-row::before { left: 6%; }
.brand-row::after { right: 6%; }
.site-brand { display: flex; align-items: center; gap: 14px; }
.site-logo { height: 52px; }
.site-name {
font-size: 28px;
font-weight: 600;
letter-spacing: .02em;
color: var(--primary-dark);
}
.site-name:hover { color: var(--primary); }
.site-search { display: flex; align-items: center; }
.site-search input {
border: 1px solid var(--border);
border-right: none;
border-radius: var(--radius-pill) 0 0 var(--radius-pill);
padding: 7px 16px;
width: 220px;
outline: none;
background: var(--card-bg);
color: var(--text);
transition: border-color .15s ease, box-shadow .15s ease;
}
.site-search input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.site-search button {
border: none;
background: var(--primary);
color: #fff;
padding: 7px 20px;
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
cursor: pointer;
transition: background .15s ease;
}
.site-search button:hover { background: var(--primary-dark); }
/* ===== 专业院系下拉(院系子站切换):胶囊开关 + 圆角白卡面板 ===== */
.site-colleges { position: relative; }
.site-colleges .colleges-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 5px 14px;
cursor: pointer;
color: var(--primary-dark);
font-weight: 600;
white-space: nowrap;
transition: border-color .15s ease, background .15s ease;
}
.site-colleges:hover .colleges-toggle {
border-color: var(--primary);
background: var(--primary-light);
}
.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--primary); }
.site-colleges .colleges-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
max-height: 420px;
overflow-y: auto;
margin: 0;
padding: 8px 0;
list-style: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-hover);
z-index: 110;
}
.site-colleges:hover .colleges-menu { display: block; }
.site-colleges .colleges-menu li a {
display: block;
margin: 0 8px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--text);
white-space: nowrap;
}
.site-colleges .colleges-menu li a:hover { background: var(--primary-light); color: var(--primary-dark); }
.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; }
/* ===== 主导航:白底 + 柔和文字层级,当前项圆点标记 ===== */
.site-nav { background: var(--card-bg); border-top: 1px solid var(--border); }
.site-nav ul {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 16px;
list-style: none;
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.site-nav li a {
position: relative;
display: block;
padding: 13px 20px;
color: var(--text);
font-weight: 500;
}
.site-nav li a:hover { color: var(--primary); }
.site-nav li.active a { color: var(--primary); font-weight: 600; }
/* 几何圆点:当前栏目下方的小圆点 */
.site-nav li.active > a::before {
content: "";
position: absolute;
left: 50%;
bottom: 5px;
width: 5px;
height: 5px;
transform: translateX(-50%);
border-radius: 50%;
background: var(--primary);
}
/* ===== 二级下拉导航:圆角白卡面板 ===== */
.site-nav > ul > li { position: relative; }
.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .7; }
.site-nav ul.sub-nav {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 168px;
max-width: none;
margin: 0;
padding: 8px 0;
list-style: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-hover);
z-index: 100;
}
.site-nav li:hover > .sub-nav { display: block; }
.site-nav .sub-nav li a {
margin: 0 8px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--text);
font-weight: 400;
white-space: nowrap;
background: none;
}
.site-nav .sub-nav li a:hover { background: var(--primary-light); color: var(--primary-dark); }
.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; }
/* ===== 首页轮播:容器内圆角卡片(非通栏),几何圆点指示器 ===== */
.banner {
position: relative;
width: min(var(--max-width), calc(100% - 32px));
margin: 24px auto 0;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-soft);
}
.banner-slides { position: relative; height: 400px; }
.banner-slide { display: none; position: absolute; inset: 0; }
.banner-slide.active { display: block; }
.banner-slide img { width: 100%; height: 100%; object-fit: cover; }
.banner-title {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 14px 20px;
color: #fff;
font-weight: 500;
background: linear-gradient(transparent, rgba(20, 63, 61, .62));
}
.banner-dots {
position: absolute;
bottom: 14px; right: 20px;
display: flex; gap: 8px;
}
.banner-dots button {
width: 9px; height: 9px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, .55);
cursor: pointer;
transition: background .15s ease, transform .15s ease;
}
.banner-dots button.active { background: #fff; transform: scale(1.25); }
/* ===== 首页区块:卡片网格(头条通栏 + 栏目块一行 3 张) ===== */
.home-main {
max-width: var(--max-width);
margin: 0 auto;
padding: 28px 16px 56px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.top-strip, .category-block {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 24px;
}
.top-strip { grid-column: 1 / -1; }
/* 栏目标题:几何圆点标记(环形点),标题中等粗细、层级柔和 */
.section-title, .block-head h2 {
margin: 0 0 14px;
font-size: 19px;
font-weight: 600;
letter-spacing: .02em;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before, .block-head h2::before {
content: "";
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 0 3.5px var(--primary-light);
}
.block-head { display: flex; align-items: center; justify-content: space-between; }
.more {
flex: none;
font-size: 13px;
color: var(--text-light);
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.more:hover {
color: var(--primary-dark);
border-color: var(--primary);
background: var(--primary-light);
}
.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; }
.top-strip li, .block-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
}
.top-strip li:last-child, .block-list li:last-child { border-bottom: none; }
.top-strip li a, .block-list li a {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-badge {
flex: none;
color: var(--primary-dark);
background: var(--primary-light);
font-size: 12px;
font-weight: 500;
padding: 2px 10px;
border-radius: var(--radius-pill);
}
/* ===== 列表页:卡片网格(默认一行 3 张;listLayout=row 切换行列表) ===== */
.list-main {
max-width: var(--max-width);
margin: 0 auto;
padding: 28px 16px 56px;
}
.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 14px; }
.breadcrumb a { margin-right: 8px; }
.breadcrumb span { margin-left: 8px; }
.page-title {
font-size: 24px;
font-weight: 600;
letter-spacing: .02em;
margin: 0 0 22px;
display: flex;
align-items: center;
gap: 10px;
}
.page-title::before {
content: "";
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 0 3.5px var(--primary-light);
}
.article-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.article-list li {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 22px 20px;
display: flex;
flex-direction: column;
gap: 10px;
transition: transform .18s ease, box-shadow .18s ease;
}
.article-list li:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); }
.item-title {
font-size: 16px;
font-weight: 600;
line-height: 1.55;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.article-list li:hover .item-title { color: var(--primary); }
.item-summary {
color: var(--text-light);
font-size: 13.5px;
line-height: 1.7;
margin: 0;
flex: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.item-meta {
display: flex;
gap: 10px;
align-items: center;
margin-top: auto;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
/* 布局变体:传统行列表(themeParams listLayout=row,见 list.html classappend) */
.list-rows .article-list { display: block; }
.list-rows .article-list li { display: block; margin-bottom: 16px; }
.list-rows .item-summary { -webkit-line-clamp: 2; flex: none; }
.list-rows .item-meta { border-top: none; padding-top: 0; margin-top: 6px; }
/* ===== 分页:胶囊页码 ===== */
.pagination {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
padding: 28px 0 4px;
}
.pagination a {
min-width: 36px;
text-align: center;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--card-bg);
font-size: 14px;
transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.pagination a:hover { color: var(--primary-dark); border-color: var(--primary); }
.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); }
/* ===== 详情/单页:整卡承载,正文舒适行距 ===== */
.detail-main {
max-width: var(--max-width);
margin: 0 auto;
padding: 28px 16px 56px;
}
.detail-main .article {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 36px 40px;
}
.article-title {
margin: 4px 0 14px;
font-size: 26px;
font-weight: 600;
letter-spacing: .02em;
text-align: center;
}
.article-meta {
display: flex;
gap: 18px;
justify-content: center;
flex-wrap: wrap;
color: var(--text-light);
font-size: 13px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.article-content { line-height: 1.9; font-size: 16px; padding-top: 22px; word-break: break-word; }
.article-content img { height: auto; border-radius: var(--radius-sm); }
.article-content table { border-collapse: collapse; width: 100%; }
.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; }
.article-sibling { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px; font-size: 14px; }
.article-sibling a {
flex: 1 1 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 14px 18px;
transition: transform .18s ease, box-shadow .18s ease, color .15s ease;
}
.article-sibling a:hover {
color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
/* ===== 页脚:浅樱粉底 + 圆点分隔条(三态渲染逻辑不动) ===== */
.site-footer {
margin-top: 40px;
background: var(--primary-light);
color: var(--text);
border-top: 1px solid var(--border);
}
.site-footer::before {
content: "";
display: block;
height: 8px;
width: min(var(--max-width), calc(100% - 32px));
margin: 18px auto 0;
background-image: radial-gradient(circle, var(--primary) 1.6px, transparent 2.2px);
background-size: 16px 8px;
background-repeat: repeat-x;
opacity: .4;
}
.footer-links {
max-width: var(--max-width);
margin: 0 auto;
padding: 18px 16px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.links-group { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; font-size: 13px; }
.group-name { font-weight: 600; margin-right: 4px; color: var(--primary-dark); }
.links-group a {
padding: 3px 10px;
border-radius: var(--radius-pill);
transition: background .15s ease, color .15s ease;
}
.links-group a:hover { background: rgba(255, 255, 255, .75); color: var(--primary-dark); }
.footer-info {
max-width: var(--max-width);
margin: 0 auto;
padding: 14px 16px 24px;
text-align: center;
font-size: 13px;
color: var(--text-light);
}
.footer-info p { margin: 4px 0; }
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
.links-group a .link-logo {
height: 20px;
width: auto;
max-width: 64px;
object-fit: contain;
vertical-align: middle;
margin-right: 4px;
border-radius: 4px;
}
/* ===== 平板(≤992px):网格降为两列,隐藏品牌行圆点装饰 ===== */
@media (max-width: 992px) {
.home-main { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.article-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.brand-row::before, .brand-row::after { display: none; }
}
/* ===== 移动端(≤768px):取消吸顶,网格单列,导航横向滑动 ===== */
@media (max-width: 768px) {
.site-header { position: static; }
.top-bar { padding: 8px 14px; justify-content: flex-start; flex-wrap: wrap; }
.top-bar .site-colleges { margin-right: 0; }
.site-search { flex: 1; }
.site-search input { flex: 1; width: auto; min-width: 0; }
.brand-row { padding: 14px 16px 10px; }
.site-logo { height: 40px; }
.site-name { font-size: 22px; }
.site-nav ul {
justify-content: flex-start;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.banner-slides { height: 200px; }
.home-main { grid-template-columns: minmax(0, 1fr); padding: 18px 14px 40px; }
.article-list { grid-template-columns: minmax(0, 1fr); }
.detail-main .article { padding: 22px 16px; }
.article-title { font-size: 21px; }
/* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */
.site-nav ul.sub-nav {
display: block;
position: static;
border: none;
box-shadow: none;
background: transparent;
min-width: 0;
padding: 0 0 6px;
}
.site-nav .sub-nav li a { margin: 0; padding: 8px 16px 8px 34px; border-radius: 0; color: var(--text-light); }
.site-nav .sub-nav li a:hover { background: var(--primary-light); color: var(--primary-dark); }
}
/* ===== 左侧栏目导航(docs/11):有二级节点的模块,栏目/文章页左侧展示兄弟目录 ===== */
.list-main.has-sidenav, .detail-main.has-sidenav { display: flex; align-items: flex-start; gap: 24px; }
.has-sidenav .list-body { flex: 1; min-width: 0; }
.side-nav { flex: none; width: 220px; background: var(--card-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
.side-nav-head { background: var(--primary); color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 18px 12px; letter-spacing: 2px; }
.side-nav-list { list-style: none; margin: 0; padding: 0; }
.side-nav-item + .side-nav-item { border-top: 1px dashed var(--border); }
.side-nav-item a { display: block; padding: 12px 16px; color: var(--text); text-decoration: none; font-size: 15px; transition: background .15s ease, color .15s ease; }
.side-nav-item a:hover { color: var(--primary); background: var(--bg); }
.side-nav-item.active a { background: var(--primary); color: #fff; font-weight: 600; text-decoration: none; }
/* 顶导航二级下拉当前项高亮(docs/11 顺带) */
.site-nav ul.sub-nav li.active a { color: var(--primary); }
@media (max-width: 768px) {
.list-main.has-sidenav, .detail-main.has-sidenav { flex-direction: column; gap: 12px; }
.side-nav { width: 100%; }
.side-nav-list { display: flex; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.side-nav-item { flex: none; }
.side-nav-item + .side-nav-item { border-top: none; border-left: 1px dashed var(--border); }
.side-nav-item a { white-space: nowrap; }
}
@media print {
.side-nav { display: none; }
.list-main.has-sidenav, .detail-main.has-sidenav { display: block; }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#EE7FA9"/><stop offset="1" stop-color="#B23A68"/>
</linearGradient></defs>
<rect width="64" height="64" fill="url(#bg)"/>
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
</svg>

Before

Width:  |  Height:  |  Size: 435 B

@@ -1,40 +0,0 @@
/* cherry-bloom 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */
(function () {
var slides = document.querySelectorAll('.banner-slide');
var dots = document.querySelectorAll('.banner-dots button');
if (!slides.length) {
return;
}
var current = 0;
var timer = null;
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() {
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, 5000);
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
window.clearInterval(timer);
show(index);
start();
});
});
start();
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

@@ -1,58 +0,0 @@
<!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="detail-main" th:classappend="${sideNav != null} ? 'has-sidenav'">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="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="list-body">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span>正文</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${article.title}">文章标题</h1>
<div class="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="article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="article-sibling">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}"
th:text="'上一篇:' + ${prevArticle.title}">上一篇</a>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚:联系信息 + 友情链接(分组渲染,方案 §8.1:友链不设独立路由) -->
<footer class="site-footer" th:fragment="footer">
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="links-group" th:each="group : ${site.linkGroups}">
<span class="group-name" th:text="${group.groupName}">分组</span>
<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="link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</div>
</div>
<div class="footer-info">
<p th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
</footer>
</html>
@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部(cherry-bloom):细线顶栏(院系切换+搜索)→ 居中品牌行 → 白底导航;th:* 数据绑定与 mint-breeze 基准逐字一致 -->
<header class="site-header" th:fragment="header">
<div class="top-bar">
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="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="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
<div class="brand-row">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
</div>
<nav class="site-nav">
<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' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<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)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="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>
@@ -1,68 +0,0 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="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="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="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):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="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>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<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>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -1,69 +0,0 @@
<!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>
<!-- listLayout 布局开关(theme.json select 参数):row=传统行列表,默认/未知值=卡片网格 -->
<main class="list-main" th:classappend="(${themeParams['listLayout'] != null and themeParams['listLayout'] == 'row'} ? 'list-rows' : '') + (${sideNav != null} ? ' has-sidenav' : '')">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="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="list-body">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (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:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -1,41 +0,0 @@
<!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="detail-main" th:classappend="${sideNav != null} ? 'has-sidenav'">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="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="list-body">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -1,22 +0,0 @@
{
"specVersion": 1,
"name": "cherry-bloom",
"title": "樱语粉韵",
"version": "1.0.0",
"author": "yunhai",
"description": "温柔樱粉风门户:细线顶栏+居中 logo 吸顶、樱花粉品牌色、大圆角 16px 卡片网格列表、花瓣式圆点装饰与暖白留白,甜美亲和,适合幼儿园与小学门户。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#DB5A8B" },
{ "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深(强调色)", "type": "color", "default": "#B23A68" },
{ "key": "primaryLight", "cssVar": "--primary-light", "label": "主色提亮", "type": "color", "default": "#FDECF3" },
{ "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#FFFAFB" },
{ "key": "cardBg", "cssVar": "--card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" },
{ "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#F7DDE7" },
{ "key": "text", "cssVar": "--text", "label": "正文文字色", "type": "color", "default": "#43333B" },
{ "key": "textLight", "cssVar": "--text-light", "label": "次要文字色", "type": "color", "default": "#9C828C" },
{ "key": "listLayout", "label": "列表布局", "type": "select", "default": "grid",
"options": [ { "value": "grid", "label": "卡片网格(一行 3 张)" }, { "value": "row", "label": "传统行列表" } ] }
]
}
@@ -8,56 +8,76 @@
-->
<th:block th:fragment="style">
<style>
/* 皮肤穿透层:站点配了 home_layout 时首页由本骨架渲染,颜色必须跟随当前皮肤变量,
否则换肤只换了页头页脚、正文区块仍是内置蓝灰(皮肤契约 §6;缺变量时回落内置蓝)。 */
:root{
--plb-primary: var(--primary, #2d8cf0);
--plb-text: var(--text, #1f2d3d);
--plb-muted: var(--text-light, #8a94a6);
--plb-card-bg: var(--card-bg, #ffffff);
--plb-page-bg: var(--bg, #f5f7fb);
--plb-band-bg: var(--bg, #f5f7fb);
--plb-border: var(--border, rgba(0, 0, 0, .08));
--plb-radius: var(--radius, 10px);
}
.plb-section{padding:26px 16px}
.plb-container{max-width:1200px;margin:0 auto}
.plb-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin:0 0 14px}
.plb-head h2{margin:0;font-size:20px;line-height:1.3;padding-left:10px;border-left:4px solid var(--primary,#2d8cf0);color:#1f2d3d}
.plb-sub{margin:2px 0 0;color:#8a94a6;font-size:12px;letter-spacing:.12em}
.plb-more{font-size:13px;color:var(--primary,#2d8cf0);text-decoration:none;white-space:nowrap}
.plb-head h2{margin:0;font-size:20px;line-height:1.3;padding-left:10px;border-left:4px solid var(--plb-primary);color:var(--plb-text)}
.plb-sub{margin:2px 0 0;color:var(--plb-muted);font-size:12px;letter-spacing:.12em}
.plb-more{font-size:13px;color:var(--plb-primary);text-decoration:none;white-space:nowrap}
.plb-more:hover{text-decoration:underline}
.plb-list{list-style:none;margin:0;padding:0}
.plb-list li{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px dashed rgba(0,0,0,.08);font-size:14px}
.plb-list li a{flex:1;min-width:0;color:#333;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-list li a:hover{color:var(--primary,#2d8cf0)}
.plb-list time{color:#999;font-size:12px;flex:none}
.plb-badge{flex:none;font-size:11px;color:#fff;background:var(--primary,#2d8cf0);border-radius:3px;padding:1px 5px}
.plb-src{flex:none;font-size:11px;color:var(--primary,#2d8cf0);border:1px solid currentColor;border-radius:3px;padding:1px 6px}
.plb-empty{color:#98a2b3;font-size:13px;padding:26px 0;text-align:center}
.plb-card{background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:20px 24px;box-shadow:0 4px 18px rgba(15,34,66,.05)}
.plb-feat{display:flex;gap:16px;margin:0 0 12px;padding:0 0 12px;border-bottom:1px solid rgba(0,0,0,.06);text-decoration:none;color:#333}
.plb-list li{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px dashed var(--plb-border);font-size:14px}
.plb-list li a{flex:1;min-width:0;color:var(--plb-text);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-list li a:hover{color:var(--plb-primary)}
.plb-list time{color:var(--plb-muted);font-size:12px;flex:none}
.plb-badge{flex:none;font-size:11px;color:#fff;background:var(--plb-primary);border-radius:3px;padding:1px 5px}
.plb-src{flex:none;font-size:11px;color:var(--plb-primary);border:1px solid currentColor;border-radius:3px;padding:1px 6px}
.plb-empty{color:var(--plb-muted);font-size:13px;padding:26px 0;text-align:center}
.plb-card{background:var(--plb-card-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius);padding:20px 24px;box-shadow:0 4px 18px rgba(15,34,66,.05)}
.plb-feat{display:flex;gap:16px;margin:0 0 12px;padding:0 0 12px;border-bottom:1px solid var(--plb-border);text-decoration:none;color:var(--plb-text)}
.plb-feat img{width:200px;height:120px;object-fit:cover;border-radius:6px;flex:none}
.plb-feat .t{display:block;font-size:16px;font-weight:600;color:#1f2d3d;margin:0 0 6px}
.plb-feat .s{display:block;color:#8a94a6;font-size:13px;line-height:1.6;margin:0 0 6px}
.plb-feat .m{color:#999;font-size:12px}
.plb-feat .t{display:block;font-size:16px;font-weight:600;color:var(--plb-text);margin:0 0 6px}
.plb-feat .s{display:block;color:var(--plb-muted);font-size:13px;line-height:1.6;margin:0 0 6px}
.plb-feat .m{color:var(--plb-muted);font-size:12px}
.plb-grid2{columns:2;column-gap:40px}
.plb-grid2 li{break-inside:avoid}
.plb-newshot{position:relative;padding:30px 0;background:#f5f7fb}
.plb-newshot{position:relative;padding:30px 0;background:var(--plb-band-bg)}
.plb-newshot-bg{position:absolute;inset:0;background-size:cover;background-position:center}
.plb-newshot-card{position:relative;background:rgba(255,255,255,.94);border-radius:10px;padding:22px 26px;box-shadow:0 8px 30px rgba(15,34,66,.08)}
.plb-newshot-card{position:relative;background:var(--plb-card-bg);border-radius:var(--plb-radius);padding:22px 26px;box-shadow:0 8px 30px rgba(15,34,66,.08)}
.plb-imagewall{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;list-style:none;margin:0;padding:0}
.plb-imagewall a{text-decoration:none;color:#333}
.plb-imagewall a{text-decoration:none;color:var(--plb-text)}
.plb-imagewall img{width:100%;height:120px;object-fit:cover;border-radius:6px;display:block}
.plb-imagewall .t{display:block;font-size:13px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-ticker{background:var(--primary,#2d8cf0);color:#fff;padding:10px 16px;font-size:14px}
.plb-ticker{background:var(--plb-primary);color:#fff;padding:10px 16px;font-size:14px}
.plb-ticker-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:14px}
.plb-ticker-label{flex:none;font-weight:600;letter-spacing:.08em}
.plb-ticker-list{display:flex;gap:26px;list-style:none;margin:0;padding:0;overflow:hidden}
.plb-ticker-list a{color:#fff;text-decoration:none;opacity:.92}
.plb-ticker-list a:hover{opacity:1;text-decoration:underline}
/* flex 项自动最小尺寸=min-content,中文可按字断行 → 不锁 nowrap/不收缩就会塌成一字一列 */
.plb-ticker-list{display:flex;gap:26px;list-style:none;margin:0;padding:0;flex:1;min-width:0;overflow:hidden;white-space:nowrap}
.plb-ticker-list li{flex:none;min-width:0}
.plb-ticker-item{display:block;flex:none;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fff;text-decoration:none;opacity:.92}
.plb-ticker-item:hover{opacity:1;text-decoration:underline}
.plb-gallery-head{text-align:center;margin:0 0 18px}
.plb-gallery-head h2{margin:0;font-size:22px;color:#1f2d3d}
.plb-gallery-head h2{margin:0;font-size:22px;color:var(--plb-text)}
.plb-gallery-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;list-style:none;margin:0;padding:0}
.plb-gallery-grid img{width:100%;height:150px;object-fit:cover;border-radius:8px;display:block}
.plb-gallery-grid .t{display:block;font-size:13px;color:#51607a;margin-top:6px;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-gallery-grid .t{display:block;font-size:13px;color:var(--plb-muted);margin-top:6px;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-gallery-grid a{text-decoration:none}
.plb-links{display:flex;flex-wrap:wrap;gap:10px;list-style:none;margin:0;padding:0}
.plb-links a{display:inline-block;font-size:13px;color:#51607a;text-decoration:none;border:1px solid rgba(0,0,0,.1);border-radius:16px;padding:4px 14px;background:#fff}
.plb-links a:hover{color:var(--primary,#2d8cf0);border-color:var(--primary,#2d8cf0)}
.plb-links a{display:inline-block;font-size:13px;color:var(--plb-muted);text-decoration:none;border:1px solid var(--plb-border);border-radius:16px;padding:4px 14px;background:var(--plb-card-bg)}
.plb-links a:hover{color:var(--plb-primary);border-color:var(--plb-primary)}
/* 全宽区块必须跨满整行:网格型主题(mint-breeze 系 .home-main 是三列)会把它们塞进单列,
非网格父容器下 grid-column 无副作用,故由骨架自带。 */
main > .plb-ticker{grid-column:1 / -1}
main > .plb-newshot{grid-column:1 / -1}
main > .plb-section{grid-column:1 / -1}
@media (max-width:768px){
.plb-grid2{grid-template-columns:1fr}
.plb-grid2{columns:1}
.plb-imagewall{grid-template-columns:repeat(2,1fr)}
.plb-gallery-grid{grid-template-columns:repeat(2,1fr)}
.plb-feat img{width:120px;height:80px}
.plb-ticker-item{max-width:150px}
}
</style>
</th:block>
@@ -92,7 +112,7 @@
<a class="plb-more" th:if="${!#strings.isEmpty(block['moreUrl'])}" th:href="${block['moreUrl']}">更多 &gt;</a>
</div>
<!-- 空态 -->
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值,写在同一标签上等于没写) -->
<div class="plb-empty" th:fragment="empty(block)">
<p>该区块暂无内容</p>
</div>
@@ -121,7 +141,9 @@
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</div>
</section>
@@ -138,7 +160,9 @@
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</div>
</section>
@@ -158,7 +182,9 @@
<span class="plb-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</div>
</section>
@@ -176,7 +202,10 @@
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
<!-- 守卫必须包在 th:block 外层:th:replace(100)先于 th:if(300)求值,写在同一标签上等于没写 -->
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</div>
</section>
@@ -193,7 +222,9 @@
</a>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
@@ -230,6 +230,7 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
/* ===== 首页轮播:容器内圆角卡片(非通栏),几何圆点指示器 ===== */
.banner {
position: relative;
grid-column: 1 / -1; /* .home-main 是三列网格,轮播必须跨满整行,否则被塞进首列变窄条 */
width: min(var(--max-width), calc(100% - 32px));
margin: 24px auto 0;
border-radius: var(--radius);
@@ -31,6 +31,18 @@
--sp-radius-lg: 10px;
--sp-speed: .28s;
--sp-font: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", Arial, sans-serif;
/* 皮肤契约别名:站点配了 home_layout 时首页走 common/fragments/home-blocks 骨架,
该骨架只认契约变量(--primary/--text/…)。不别名过去,换肤就只换了页头页脚、正文区块仍是骨架内置色。 */
--primary: var(--sp-primary);
--primary-dark: var(--sp-primary-dark);
--primary-light: var(--sp-primary-light);
--text: var(--sp-text);
--text-light: var(--sp-text-light);
--bg: var(--sp-bg);
--card-bg: var(--sp-card-bg);
--border: var(--sp-border);
--radius: var(--sp-radius-lg);
}
/* ---------------------------------------------------------------------
@@ -1,35 +0,0 @@
# violet-iris 皮肤包
由 mint-breeze 派生的雅致鸢尾紫皮肤(docs/10 契约 §5 工作流)。**升级随版本发布,不在站点间复制目录**。
## 风格定位
沉静、书卷气,适合高校与教科研机构门户:鸢尾紫品牌色、圆角 12px、卡片纯白+淡紫柔和阴影、细线顶栏+居中 logo(桌面吸顶,移动端取消)、列表卡片网格(一行 3 张)、几何圆点装饰与大留白;无纹理、无通栏大图、无复杂动效。
## 数据契约(模板可取用的 model 属性全集,契约 §3)
| 页面 | 属性 |
| --- | --- |
| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/violet-iris`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) |
| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) |
| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) |
| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` |
| single | `category`(含 `content`)、`currentCategoryId` |
原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。
## 轮播图尺寸约定(bannerSize)
1920x560(管理端上传端按此引导裁剪,D2 决策点)。
## 可调参数
见 `theme.json` 的 `params`:
- **color**(进 CSS 变量):`primary` 主色 / `primaryDark` 主色加深(即强调色,用于品牌文字与悬停加深)/ `primaryLight` 主色提亮 / `bg` 页面底色 / `cardBg` 卡片底色 / `border` 边框色 / `text` 正文文字 / `textLight` 次要文字。
- **select**:`listLayout` = `grid`(默认,卡片网格一行 3 张)或 `row`(传统行列表)。
换色建议:主色系三值同步调整(primary / primaryDark / primaryLight),bg 与 border、primaryLight 保持同族浅色调,避免底色与卡片对比失衡。
优先级:`custom_css` > `themeVarsCss` > `main.css`。
@@ -1,616 +0,0 @@
/* ============================================================
* violet-iris 皮肤包样式(mint-breeze 派生,docs/10 皮肤包契约 §5)
* 雅致鸢尾紫风:鸢尾紫品牌色 + 圆角 12px + 柔和紫阴影 + 卡片网格列表
* + 几何圆点装饰与淡薰衣草留白;无纹理、无通栏大图、无复杂动效。
* 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪)。
* ============================================================ */
:root {
--primary: #6552D9;
--primary-dark: #4A39B0;
--primary-light: #EFECFC;
--text: #332F45;
--text-light: #847E9C;
--bg: #F8F7FD;
--card-bg: #FFFFFF;
--border: #E4DFF6;
--radius: 12px;
--radius-sm: 8px;
--radius-pill: 999px;
--shadow-soft: 0 6px 18px rgba(74, 57, 176, .08);
--shadow-hover: 0 12px 28px rgba(74, 57, 176, .14);
--max-width: 1200px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--bg);
}
a { color: inherit; text-decoration: none; transition: color .15s ease; }
a:hover { color: var(--primary); }
img { max-width: 100%; }
time, .item-meta { color: var(--text-light); font-size: 13px; }
.empty { color: var(--text-light); text-align: center; padding: 32px 0; }
/* ===== 头部:细线顶栏 + 居中品牌行 + 白底导航(桌面吸顶) ===== */
.site-header {
position: sticky;
top: 0;
z-index: 90;
background: var(--card-bg);
box-shadow: var(--shadow-soft);
}
/* 细线顶栏:院系切换在左、站内搜索在右;院系未配置时搜索仍靠右 */
.top-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 7px max(16px, calc((100% - var(--max-width)) / 2 + 16px));
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.top-bar .site-colleges { margin-right: auto; }
/* 居中品牌行:两侧几何圆点装饰(纯 CSS,无纹理) */
.brand-row {
position: relative;
display: flex;
justify-content: center;
padding: 18px 16px 14px;
}
.brand-row::before, .brand-row::after {
content: "";
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 46px;
height: 10px;
opacity: .45;
background-image:
radial-gradient(circle, var(--primary) 2.2px, transparent 2.8px),
radial-gradient(circle, var(--primary) 1.6px, transparent 2.2px),
radial-gradient(circle, var(--primary) 1.1px, transparent 1.7px);
background-repeat: no-repeat;
background-position: left center, center center, right center;
}
.brand-row::before { left: 6%; }
.brand-row::after { right: 6%; }
.site-brand { display: flex; align-items: center; gap: 14px; }
.site-logo { height: 52px; }
.site-name {
font-size: 28px;
font-weight: 600;
letter-spacing: .02em;
color: var(--primary-dark);
}
.site-name:hover { color: var(--primary); }
.site-search { display: flex; align-items: center; }
.site-search input {
border: 1px solid var(--border);
border-right: none;
border-radius: var(--radius-pill) 0 0 var(--radius-pill);
padding: 7px 16px;
width: 220px;
outline: none;
background: var(--card-bg);
color: var(--text);
transition: border-color .15s ease, box-shadow .15s ease;
}
.site-search input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.site-search button {
border: none;
background: var(--primary);
color: #fff;
padding: 7px 20px;
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
cursor: pointer;
transition: background .15s ease;
}
.site-search button:hover { background: var(--primary-dark); }
/* ===== 专业院系下拉(院系子站切换):胶囊开关 + 圆角白卡面板 ===== */
.site-colleges { position: relative; }
.site-colleges .colleges-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 5px 14px;
cursor: pointer;
color: var(--primary-dark);
font-weight: 600;
white-space: nowrap;
transition: border-color .15s ease, background .15s ease;
}
.site-colleges:hover .colleges-toggle {
border-color: var(--primary);
background: var(--primary-light);
}
.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--primary); }
.site-colleges .colleges-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
max-height: 420px;
overflow-y: auto;
margin: 0;
padding: 8px 0;
list-style: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-hover);
z-index: 110;
}
.site-colleges:hover .colleges-menu { display: block; }
.site-colleges .colleges-menu li a {
display: block;
margin: 0 8px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--text);
white-space: nowrap;
}
.site-colleges .colleges-menu li a:hover { background: var(--primary-light); color: var(--primary-dark); }
.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; }
/* ===== 主导航:白底 + 柔和文字层级,当前项圆点标记 ===== */
.site-nav { background: var(--card-bg); border-top: 1px solid var(--border); }
.site-nav ul {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 16px;
list-style: none;
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.site-nav li a {
position: relative;
display: block;
padding: 13px 20px;
color: var(--text);
font-weight: 500;
}
.site-nav li a:hover { color: var(--primary); }
.site-nav li.active a { color: var(--primary); font-weight: 600; }
/* 几何圆点:当前栏目下方的小圆点 */
.site-nav li.active > a::before {
content: "";
position: absolute;
left: 50%;
bottom: 5px;
width: 5px;
height: 5px;
transform: translateX(-50%);
border-radius: 50%;
background: var(--primary);
}
/* ===== 二级下拉导航:圆角白卡面板 ===== */
.site-nav > ul > li { position: relative; }
.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .7; }
.site-nav ul.sub-nav {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 168px;
max-width: none;
margin: 0;
padding: 8px 0;
list-style: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-hover);
z-index: 100;
}
.site-nav li:hover > .sub-nav { display: block; }
.site-nav .sub-nav li a {
margin: 0 8px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--text);
font-weight: 400;
white-space: nowrap;
background: none;
}
.site-nav .sub-nav li a:hover { background: var(--primary-light); color: var(--primary-dark); }
.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; }
/* ===== 首页轮播:容器内圆角卡片(非通栏),几何圆点指示器 ===== */
.banner {
position: relative;
width: min(var(--max-width), calc(100% - 32px));
margin: 24px auto 0;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-soft);
}
.banner-slides { position: relative; height: 400px; }
.banner-slide { display: none; position: absolute; inset: 0; }
.banner-slide.active { display: block; }
.banner-slide img { width: 100%; height: 100%; object-fit: cover; }
.banner-title {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 14px 20px;
color: #fff;
font-weight: 500;
background: linear-gradient(transparent, rgba(20, 63, 61, .62));
}
.banner-dots {
position: absolute;
bottom: 14px; right: 20px;
display: flex; gap: 8px;
}
.banner-dots button {
width: 9px; height: 9px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, .55);
cursor: pointer;
transition: background .15s ease, transform .15s ease;
}
.banner-dots button.active { background: #fff; transform: scale(1.25); }
/* ===== 首页区块:卡片网格(头条通栏 + 栏目块一行 3 张) ===== */
.home-main {
max-width: var(--max-width);
margin: 0 auto;
padding: 28px 16px 56px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.top-strip, .category-block {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 24px;
}
.top-strip { grid-column: 1 / -1; }
/* 栏目标题:几何圆点标记(环形点),标题中等粗细、层级柔和 */
.section-title, .block-head h2 {
margin: 0 0 14px;
font-size: 19px;
font-weight: 600;
letter-spacing: .02em;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before, .block-head h2::before {
content: "";
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 0 3.5px var(--primary-light);
}
.block-head { display: flex; align-items: center; justify-content: space-between; }
.more {
flex: none;
font-size: 13px;
color: var(--text-light);
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.more:hover {
color: var(--primary-dark);
border-color: var(--primary);
background: var(--primary-light);
}
.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; }
.top-strip li, .block-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
}
.top-strip li:last-child, .block-list li:last-child { border-bottom: none; }
.top-strip li a, .block-list li a {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-badge {
flex: none;
color: var(--primary-dark);
background: var(--primary-light);
font-size: 12px;
font-weight: 500;
padding: 2px 10px;
border-radius: var(--radius-pill);
}
/* ===== 列表页:卡片网格(默认一行 3 张;listLayout=row 切换行列表) ===== */
.list-main {
max-width: var(--max-width);
margin: 0 auto;
padding: 28px 16px 56px;
}
.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 14px; }
.breadcrumb a { margin-right: 8px; }
.breadcrumb span { margin-left: 8px; }
.page-title {
font-size: 24px;
font-weight: 600;
letter-spacing: .02em;
margin: 0 0 22px;
display: flex;
align-items: center;
gap: 10px;
}
.page-title::before {
content: "";
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 0 3.5px var(--primary-light);
}
.article-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.article-list li {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 22px 20px;
display: flex;
flex-direction: column;
gap: 10px;
transition: transform .18s ease, box-shadow .18s ease;
}
.article-list li:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); }
.item-title {
font-size: 16px;
font-weight: 600;
line-height: 1.55;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.article-list li:hover .item-title { color: var(--primary); }
.item-summary {
color: var(--text-light);
font-size: 13.5px;
line-height: 1.7;
margin: 0;
flex: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.item-meta {
display: flex;
gap: 10px;
align-items: center;
margin-top: auto;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
/* 布局变体:传统行列表(themeParams listLayout=row,见 list.html classappend) */
.list-rows .article-list { display: block; }
.list-rows .article-list li { display: block; margin-bottom: 16px; }
.list-rows .item-summary { -webkit-line-clamp: 2; flex: none; }
.list-rows .item-meta { border-top: none; padding-top: 0; margin-top: 6px; }
/* ===== 分页:胶囊页码 ===== */
.pagination {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
padding: 28px 0 4px;
}
.pagination a {
min-width: 36px;
text-align: center;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--card-bg);
font-size: 14px;
transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.pagination a:hover { color: var(--primary-dark); border-color: var(--primary); }
.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); }
/* ===== 详情/单页:整卡承载,正文舒适行距 ===== */
.detail-main {
max-width: var(--max-width);
margin: 0 auto;
padding: 28px 16px 56px;
}
.detail-main .article {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 36px 40px;
}
.article-title {
margin: 4px 0 14px;
font-size: 26px;
font-weight: 600;
letter-spacing: .02em;
text-align: center;
}
.article-meta {
display: flex;
gap: 18px;
justify-content: center;
flex-wrap: wrap;
color: var(--text-light);
font-size: 13px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.article-content { line-height: 1.9; font-size: 16px; padding-top: 22px; word-break: break-word; }
.article-content img { height: auto; border-radius: var(--radius-sm); }
.article-content table { border-collapse: collapse; width: 100%; }
.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; }
.article-sibling { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px; font-size: 14px; }
.article-sibling a {
flex: 1 1 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: 14px 18px;
transition: transform .18s ease, box-shadow .18s ease, color .15s ease;
}
.article-sibling a:hover {
color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
/* ===== 页脚:浅鸢尾底 + 圆点分隔条(三态渲染逻辑不动) ===== */
.site-footer {
margin-top: 40px;
background: var(--primary-light);
color: var(--text);
border-top: 1px solid var(--border);
}
.site-footer::before {
content: "";
display: block;
height: 8px;
width: min(var(--max-width), calc(100% - 32px));
margin: 18px auto 0;
background-image: radial-gradient(circle, var(--primary) 1.6px, transparent 2.2px);
background-size: 16px 8px;
background-repeat: repeat-x;
opacity: .4;
}
.footer-links {
max-width: var(--max-width);
margin: 0 auto;
padding: 18px 16px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.links-group { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; font-size: 13px; }
.group-name { font-weight: 600; margin-right: 4px; color: var(--primary-dark); }
.links-group a {
padding: 3px 10px;
border-radius: var(--radius-pill);
transition: background .15s ease, color .15s ease;
}
.links-group a:hover { background: rgba(255, 255, 255, .75); color: var(--primary-dark); }
.footer-info {
max-width: var(--max-width);
margin: 0 auto;
padding: 14px 16px 24px;
text-align: center;
font-size: 13px;
color: var(--text-light);
}
.footer-info p { margin: 4px 0; }
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
.links-group a .link-logo {
height: 20px;
width: auto;
max-width: 64px;
object-fit: contain;
vertical-align: middle;
margin-right: 4px;
border-radius: 4px;
}
/* ===== 平板(≤992px):网格降为两列,隐藏品牌行圆点装饰 ===== */
@media (max-width: 992px) {
.home-main { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.article-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.brand-row::before, .brand-row::after { display: none; }
}
/* ===== 移动端(≤768px):取消吸顶,网格单列,导航横向滑动 ===== */
@media (max-width: 768px) {
.site-header { position: static; }
.top-bar { padding: 8px 14px; justify-content: flex-start; flex-wrap: wrap; }
.top-bar .site-colleges { margin-right: 0; }
.site-search { flex: 1; }
.site-search input { flex: 1; width: auto; min-width: 0; }
.brand-row { padding: 14px 16px 10px; }
.site-logo { height: 40px; }
.site-name { font-size: 22px; }
.site-nav ul {
justify-content: flex-start;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.banner-slides { height: 200px; }
.home-main { grid-template-columns: minmax(0, 1fr); padding: 18px 14px 40px; }
.article-list { grid-template-columns: minmax(0, 1fr); }
.detail-main .article { padding: 22px 16px; }
.article-title { font-size: 21px; }
/* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */
.site-nav ul.sub-nav {
display: block;
position: static;
border: none;
box-shadow: none;
background: transparent;
min-width: 0;
padding: 0 0 6px;
}
.site-nav .sub-nav li a { margin: 0; padding: 8px 16px 8px 34px; border-radius: 0; color: var(--text-light); }
.site-nav .sub-nav li a:hover { background: var(--primary-light); color: var(--primary-dark); }
}
/* ===== 左侧栏目导航(docs/11):有二级节点的模块,栏目/文章页左侧展示兄弟目录 ===== */
.list-main.has-sidenav, .detail-main.has-sidenav { display: flex; align-items: flex-start; gap: 24px; }
.has-sidenav .list-body { flex: 1; min-width: 0; }
.side-nav { flex: none; width: 220px; background: var(--card-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
.side-nav-head { background: var(--primary); color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 18px 12px; letter-spacing: 2px; }
.side-nav-list { list-style: none; margin: 0; padding: 0; }
.side-nav-item + .side-nav-item { border-top: 1px dashed var(--border); }
.side-nav-item a { display: block; padding: 12px 16px; color: var(--text); text-decoration: none; font-size: 15px; transition: background .15s ease, color .15s ease; }
.side-nav-item a:hover { color: var(--primary); background: var(--bg); }
.side-nav-item.active a { background: var(--primary); color: #fff; font-weight: 600; text-decoration: none; }
/* 顶导航二级下拉当前项高亮(docs/11 顺带) */
.site-nav ul.sub-nav li.active a { color: var(--primary); }
@media (max-width: 768px) {
.list-main.has-sidenav, .detail-main.has-sidenav { flex-direction: column; gap: 12px; }
.side-nav { width: 100%; }
.side-nav-list { display: flex; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.side-nav-item { flex: none; }
.side-nav-item + .side-nav-item { border-top: none; border-left: 1px dashed var(--border); }
.side-nav-item a { white-space: nowrap; }
}
@media print {
.side-nav { display: none; }
.list-main.has-sidenav, .detail-main.has-sidenav { display: block; }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8574E8"/><stop offset="1" stop-color="#4A39B0"/>
</linearGradient></defs>
<rect width="64" height="64" fill="url(#bg)"/>
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
</svg>

Before

Width:  |  Height:  |  Size: 435 B

@@ -1,40 +0,0 @@
/* violet-iris 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */
(function () {
var slides = document.querySelectorAll('.banner-slide');
var dots = document.querySelectorAll('.banner-dots button');
if (!slides.length) {
return;
}
var current = 0;
var timer = null;
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() {
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, 5000);
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
window.clearInterval(timer);
show(index);
start();
});
});
start();
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

@@ -1,58 +0,0 @@
<!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="detail-main" th:classappend="${sideNav != null} ? 'has-sidenav'">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="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="list-body">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span>正文</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${article.title}">文章标题</h1>
<div class="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="article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="article-sibling">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}"
th:text="'上一篇:' + ${prevArticle.title}">上一篇</a>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚:联系信息 + 友情链接(分组渲染,方案 §8.1:友链不设独立路由) -->
<footer class="site-footer" th:fragment="footer">
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="links-group" th:each="group : ${site.linkGroups}">
<span class="group-name" th:text="${group.groupName}">分组</span>
<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="link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</div>
</div>
<div class="footer-info">
<p th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
</footer>
</html>
@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部(violet-iris):细线顶栏(院系切换+搜索)→ 居中品牌行 → 白底导航;th:* 数据绑定与 mint-breeze 基准逐字一致 -->
<header class="site-header" th:fragment="header">
<div class="top-bar">
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="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="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
<div class="brand-row">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
</div>
<nav class="site-nav">
<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' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<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)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="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>
@@ -1,68 +0,0 @@
<!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="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="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="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="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):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="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>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<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>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -1,69 +0,0 @@
<!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>
<!-- listLayout 布局开关(theme.json select 参数):row=传统行列表,默认/未知值=卡片网格 -->
<main class="list-main" th:classappend="(${themeParams['listLayout'] != null and themeParams['listLayout'] == 'row'} ? 'list-rows' : '') + (${sideNav != null} ? ' has-sidenav' : '')">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="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="list-body">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (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:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -1,41 +0,0 @@
<!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="detail-main" th:classappend="${sideNav != null} ? 'has-sidenav'">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="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="list-body">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -1,22 +0,0 @@
{
"specVersion": 1,
"name": "violet-iris",
"title": "鸢尾紫韵",
"version": "1.0.0",
"author": "yunhai",
"description": "雅致鸢尾紫门户:细线顶栏+居中 logo 吸顶、鸢尾紫品牌色、圆角 12px 卡片网格列表、几何圆点装饰与淡薰衣草留白,沉静书卷气,适合高校与教科研机构。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#6552D9" },
{ "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深(强调色)", "type": "color", "default": "#4A39B0" },
{ "key": "primaryLight", "cssVar": "--primary-light", "label": "主色提亮", "type": "color", "default": "#EFECFC" },
{ "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#F8F7FD" },
{ "key": "cardBg", "cssVar": "--card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" },
{ "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#E4DFF6" },
{ "key": "text", "cssVar": "--text", "label": "正文文字色", "type": "color", "default": "#332F45" },
{ "key": "textLight", "cssVar": "--text-light", "label": "次要文字色", "type": "color", "default": "#847E9C" },
{ "key": "listLayout", "label": "列表布局", "type": "select", "default": "grid",
"options": [ { "value": "grid", "label": "卡片网格(一行 3 张)" }, { "value": "row", "label": "传统行列表" } ] }
]
}
@@ -35,12 +35,13 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
private Integer catNews;
private Integer catNotice;
private Integer siteId;
@BeforeEach
void seed() {
purgeTables();
CmsSite site = persistSite("layhome", 1, "布局测试门户");
Integer siteId = site.getId();
siteId = site.getId();
catNews = persistCategory(siteId, 0, "校园时讯", CategoryTypeEnum.LIST).getId();
catNotice = persistCategory(siteId, 0, "通知公告", CategoryTypeEnum.LIST).getId();
persistCategory(siteId, 0, "学校概况", CategoryTypeEnum.SINGLE);
@@ -118,7 +119,7 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
assertThat(html).doesNotContain("headline-list").doesNotContain("category-block");
// ticker:置顶优先+发布时间倒序取 size=2 条(无置顶 → 最新两篇)
assertThat(html).contains("plb-ticker").contains("要闻速递");
assertThat(count(html, "plb-ticker-item")).as("ticker 取 size=2 条").isEqualTo(2);
assertThat(count(html, "<a class=\"plb-ticker-item\"")).as("ticker 取 size=2 条").isEqualTo(2);
// article_list:多栏目聚合 + compact 形态 + size=2;标题用区块 title 而非栏目名
assertThat(html).contains("plb-section").contains("plb-compact");
assertThat(html).contains("要闻公告");
@@ -180,4 +181,74 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
// 未知 type 块被跳过;其余仍为布局模式(空 blocks 有效,但该配置 blocks=[未知] → 序列为空 → 仅骨架)
assertThat(html).doesNotContain("richtext");
}
@Test
@DisplayName("academic-navy(学院蓝派生)布局模式:区块走皮肤自有 fragment,不落通用骨架")
void academicNavy_layoutUsesOwnFragments() throws Exception {
Integer emptyCat = persistCategory(siteId, 0, "空置栏目", CategoryTypeEnum.LIST).getId();
setConfig("layhome", "{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"ticker\",\"title\":\"要闻速递\",\"size\":3},"
+ "{\"id\":\"b2\",\"type\":\"article_list\",\"title\":\"校园要闻\",\"categoryIds\":[" + catNews + "],\"style\":\"news_grid\",\"size\":6},"
+ "{\"id\":\"b3\",\"type\":\"article_list\",\"title\":\"通知公告\",\"categoryIds\":[" + catNotice + "],\"style\":\"compact\",\"size\":4},"
+ "{\"id\":\"b4\",\"type\":\"article_list\",\"title\":\"空置栏目\",\"categoryIds\":[" + emptyCat + "],\"style\":\"compact\",\"size\":4}"
+ "]}}");
siteRepository.findByCode("layhome").ifPresent(s -> {
s.setTheme("academic-navy");
siteRepository.saveAndFlush(s);
});
clearCaches();
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
// 皮肤自有区块:an-* 结构类;news_grid/compact 不再落 plb 骨架
assertThat(html).contains("an-ticker").contains("an-split").contains("an-date-chip");
assertThat(html).as("已实现形态不得回落通用骨架").doesNotContain("plb-");
assertThat(html).contains("校园要闻").contains("通知公告").contains("要闻速递");
assertThat(count(html, "<a class=\"an-ticker-item\"")).as("ticker 取 size=3 条").isEqualTo(3);
assertThat(count(html, "an-block-item-title"))
.as("news_grid 头条外的 5 行(size6-1)+ compact 3 行(通知公告仅 3 篇)").isEqualTo(8);
assertThat(count(html, "栏目暂无内容")).as("仅空置栏目显示空态").isEqualTo(1);
}
@Test
@DisplayName("campus-azure(学院蓝派生)回落分支与列表页可渲染,主题资产与必备模板齐备")
void campusAzure_fallbackAndAssets() throws Exception {
siteRepository.findByCode("layhome").ifPresent(s -> {
s.setTheme("campus-azure");
siteRepository.saveAndFlush(s);
});
clearCaches();
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(html).contains("cz-header").contains("cz-footer").contains("校园时讯");
assertThat(html).as("无 home_layout 时不得出现布局骨架").doesNotContain("plb-section");
String css = body(mockMvc.perform(get("/portal/layhome/assets/campus-azure/css/main.css"))
.andExpect(status().isOk()).andReturn());
// CSS 里是 "--primary: var(--cz-primary);",声明带空格,断言前先去空白
assertThat(css.replaceAll("\\s+", ""))
.as("资产通道按主题名服务 CSS,且私有变量已别名到契约变量")
.contains("--cz-primary").contains("--primary:var(--cz-primary)");
}
@Test
@DisplayName("空态守卫:有内容的区块不得渲染「该区块暂无内容」,空区块恰渲染一次")
void emptyState_onlyForEmptyBlocks() throws Exception {
Integer emptyCat = persistCategory(siteId, 0, "空置栏目", CategoryTypeEnum.LIST).getId();
clearCaches();
setConfig("layhome", "{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"article_list\",\"title\":\"校园时讯\",\"categoryIds\":[" + catNews + "],\"style\":\"compact\",\"size\":3},"
+ "{\"id\":\"b2\",\"type\":\"article_list\",\"title\":\"校园要闻\",\"categoryIds\":[" + catNews + "],\"style\":\"news_grid\",\"size\":3},"
+ "{\"id\":\"b3\",\"type\":\"article_list\",\"title\":\"空置栏目\",\"categoryIds\":[" + emptyCat + "],\"style\":\"compact\",\"size\":3}"
+ "]}}");
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
// th:replace(优先级 100)先于 th:if(300)求值:守卫写在同一标签上等于没写,
// 空态块会被无条件插入——线上表现为每个有内容的栏目卡下面都挂一句「该区块暂无内容」。
assertThat(count(html, "该区块暂无内容"))
.as("三块中仅空置栏目该显示空态")
.isEqualTo(1);
assertThat(html).contains("校园时讯").contains("校园要闻");
}
}
@@ -0,0 +1,194 @@
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;
/**
* 高校官网风皮肤契约(academic-navy / campus-azure,以 scholar-blue 为基座派生)。
*
* 线上事故复盘:站点配了 home_layout 时首页走 common 骨架,派生包若只改色不改区块形态,
* 首页正文就是骨架内置的蓝灰卡片——用户看到的"皮肤"只剩页头页脚,且中文标题被 flex 压塌。
* 故这里要求派生包必须自带 home-blocks fragment,并钉住三条渲染不变量。
*/
@DisplayName("高校官网风皮肤契约 - 自带首页区块 + 学术蓝配色")
class AcademicSkinContractTest {
/** 皮肤名:结构类名前缀,两处必须一致(拷贝忘改前缀会让两套皮肤互相串样式)。 */
private static final String[] SKINS = {"academic-navy:an", "campus-azure:cz"};
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 = AcademicSkinContractTest.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);
// 定位元素默认绘制在普通流之上:不带 z-index 的背景带会糊在卡片正面(实测整卡泛绿)
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("皮肤预览图必须是实拍图,不得沿用基座 scholar-blue 的截图")
void screenshotMustBeOwnCapture() {
byte[] base = bytes("/templates/themes/scholar-blue/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 = AcademicSkinContractTest.class.getResourceAsStream(classpath)) {
assertThat(in).as("classpath 资源必须存在:%s", classpath).isNotNull();
return readAll(in);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@Test
@DisplayName("配色必须是学术蓝:主色蓝通道主导、明度落在深蓝区间,不得回落粉紫")
void primaryMustBeAcademicBlue() 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;
assertThat(b).as("主色须蓝通道主导(粉/紫已被否决),实际 %s", primary).isGreaterThan(r);
assertThat(b).as("主色须蓝通道主导,实际 %s", primary).isGreaterThan(g);
double lightness = (Math.max(r, Math.max(g, b)) + Math.min(r, Math.min(g, b))) / 255d / 2d;
assertThat(lightness).as("官网主色应为深蓝/藏蓝而非浅粉彩,实际 %s", primary)
.isBetween(0.18d, 0.52d);
}
}
}
@@ -0,0 +1,115 @@
package com.yunhai.cms.theme;
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;
/**
* 通用兜底区块骨架(themes/common/fragments/home-blocks.html)的 CSS 契约。
*
* 该片段带内联 style,任何配了 home_layout 的站点都用它渲染区块,是门户首页的实际门面;
* 它出问题时十套皮肤一起坏,且只在"站点配了首页布局"时才暴露——回落分支看不到。
* 故把三条易被改坏的不变量钉在这里:不被中文压塌、颜色穿透皮肤、全宽区块不被网格切列。
*/
@DisplayName("home-blocks 共享骨架 CSS 契约")
class HomeBlocksCssContractTest {
private static final String FRAGMENT = "/templates/themes/common/fragments/home-blocks.html";
/** 取 ::style 片段里的 CSS 正文(第一个 <style> 块)。 */
private static String styleBlock() {
try (InputStream in = HomeBlocksCssContractTest.class.getResourceAsStream(FRAGMENT)) {
assertThat(in).as("共享骨架片段必须存在于 classpath").isNotNull();
String html = new String(readAll(in), StandardCharsets.UTF_8);
Matcher m = Pattern.compile("<style>(.*?)</style>", Pattern.DOTALL).matcher(html);
assertThat(m.find()).as("片段应携带内联 <style>").isTrue();
return m.group(1);
} 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("骨架应含选择器 %s 的规则", selector).isTrue();
return m.group(1).replaceAll("\\s+", "");
}
@Test
@DisplayName("要闻速递不被中文压塌:列表可收缩+不换行,条目不缩、超长省略")
void tickerMustNotCollapseCjkTextIntoVerticalColumns() {
String css = styleBlock();
String list = ruleOf(css, ".plb-ticker-list");
// flex 项的自动最小尺寸是 min-content,中文可按字断行 → 不加 min-width:0 就会塌成一字一列
assertThat(list).as("列表须让出宽度收缩权").contains("min-width:0");
assertThat(list).as("速递条必须单行").contains("white-space:nowrap");
String item = ruleOf(css, ".plb-ticker-item");
assertThat(item).as("条目不得被压缩").contains("flex:none");
assertThat(item).as("超长标题省略号收尾").contains("text-overflow:ellipsis");
}
@Test
@DisplayName("骨架颜色走 --plb-* 变量层并穿透皮肤变量,不留裸色值")
void skeletonColorsMustFlowFromThemeVariables() {
String css = styleBlock();
assertThat(css).as("应声明 --plb-* 变量层").contains("--plb-text:").contains("--plb-muted:");
assertThat(css).contains("--plb-card-bg:").contains("--plb-border:").contains("--plb-band-bg:");
// 变量层必须挂到皮肤契约变量上,否则换肤对首页区块无效
assertThat(css).contains("var(--text").contains("var(--primary");
// 兜底值允许写在 var() 第二参里,但不得再出现裸色声明
assertThat(css).as("标题色应走变量").doesNotContain("color:#1f2d3d");
assertThat(css).as("次要文字色应走变量").doesNotContain("color:#8a94a6");
assertThat(css).as("新闻背景带应走变量").doesNotContain("background:#f5f7fb");
assertThat(css).as("正文色应走变量").doesNotContain("color:#333;");
}
@Test
@DisplayName("全宽区块在网格型主题里跨满整行,不被塞进单列")
void fullWidthBlocksMustSpanAllGridColumns() {
String css = styleBlock();
for (String selector : new String[] {"main>.plb-ticker", "main>.plb-newshot", "main>.plb-section"}) {
String compact = css.replaceAll("\\s+", "");
assertThat(compact).as("%s 应跨满网格整行(非网格父容器下该属性无副作用)", selector)
.contains(selector + "{grid-column:1/-1}");
}
}
@Test
@DisplayName("学院蓝基座把 --sp-* 变量别名到皮肤契约变量,供骨架继承")
void scholarBlueMustAliasItsVarsToThemeContractVars() {
String css = themeCss("scholar-blue");
String root = ruleOf(css, ":root");
assertThat(root).as("骨架用 --primary,学院蓝主色须别名过去").contains("--primary:var(--sp-primary)");
assertThat(root).contains("--text:var(--sp-text)").contains("--card-bg:var(--sp-card-bg)");
assertThat(root).contains("--border:var(--sp-border)").contains("--text-light:var(--sp-text-light)");
}
private static String themeCss(String theme) {
String path = "/templates/themes/" + theme + "/assets/css/main.css";
try (InputStream in = HomeBlocksCssContractTest.class.getResourceAsStream(path)) {
assertThat(in).as("皮肤 %s 的 main.css 必须存在", theme).isNotNull();
return new String(readAll(in), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
@@ -105,11 +105,12 @@ class ThemeRegistryTest {
}
@Test
@DisplayName("新增包: cherry-bloom/violet-iris 注册成功,primary 与 default 及彼此互异(拷贝忘改哨兵)")
void cherryBloomAndVioletIrisRegisteredWithDistinctPrimary() {
@DisplayName("高校官网风新包: academic-navy/campus-azure 注册成功,主色与 default/scholar-blue 及彼此互异")
void academicSkinsRegisteredWithDistinctPrimary() {
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[] {"cherry-bloom", "violet-iris"}) {
for (String name : new String[] {"academic-navy", "campus-azure"}) {
ThemeInfo info = registry.get(name);
assertThat(info).as("%s 皮肤包必须注册(清单+必备模板+截图齐全)", name).isNotNull();
assertThat(info.getTitle()).isNotBlank();
@@ -117,9 +118,21 @@ class ThemeRegistryTest {
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("cherry-bloom")).isNotEqualTo(seen.get("violet-iris"));
assertThat(seen.get("academic-navy")).isNotEqualTo(seen.get("campus-azure"));
}
@Test
@DisplayName("下线包: cherry-bloom/violet-iris 不再注册(粉/紫两包不符高校官网风,用户否决)")
void rejectedPinkSkinsAreGone() {
for (String name : new String[] {"cherry-bloom", "violet-iris"}) {
assertThat(registry.contains(name)).as("%s 必须已从皮肤库移除", name).isFalse();
assertThat(registry.get(name)).isNull();
}
}
@Test