feat(portal): 门户左侧栏目导航+首页视觉升级+主题正名批次-PortalSideNav模型+attachCategoryNavigation注入doCategory/doArticle三件套categoryPath/navTopId/sideNav(站点快照树推导零新增查询,home/search不注入);7主题侧栏两栏markup+窄屏横滚+顶导航祖先高亮+二级下拉当前项高亮+面包屑全路径;cms_category.home_style全链路(news_grid 16条8x2日期前缀/media_focus形态)+PortalListData.Item补source来源徽标四处装配;Banner机制gallery展示位→galleryBanners四列大图轮播(自由字符串零DDL);页脚config 12新键(footer_addresses多校区/qr_*/social_*含URL校验,footerAddressLines由PortalSiteSnapshot.of拆行派生);新增展示型皮肤包campus-glow;fresh-mint2正名fresh-mint(theme.json/头注释同步);AttachmentService scene白名单加category(单页富文本贴图免清孤儿);新增PortalSideNavIT 8+PortalHomeBlocksIT 3,CategoryServiceIT/SiteServiceIT/PortalSsrIT/ThemeContractIT等单类独跑全绿;附sql/clear_cms_data.sql全量清库脚本(清cms_*六表留平台账号/主题包/上传文件,测试数据重走用)
build / build-push (push) Successful in 1m47s

This commit is contained in:
chenxing
2026-09-10 11:10:48 +08:00
parent f4b1749377
commit 98d640faba
77 changed files with 2564 additions and 175 deletions
@@ -342,3 +342,29 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
margin-right: 4px;
border-radius: 2px;
}
/* ===== 左侧栏目导航(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; }
}
@@ -4,10 +4,25 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</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>
@@ -35,6 +50,7 @@
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -33,14 +33,15 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
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}">
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="list-main">
<main class="list-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" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
<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>
@@ -42,6 +59,7 @@
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<span th:text="${category.name}">栏目</span>
<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">
@@ -16,6 +33,7 @@
<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>
@@ -0,0 +1,30 @@
# campus-glow(炫彩校园)
展示型皮肤包(docs/12 方案的首个落地消费方):在共享数据契约之上,为首页提供四类「炫」呈现,并大幅增强页脚个性化能力。
## 风格定位
- 主色翠绿 `#0e8a5f` + 强调橙 `#ff7a1a`(gradual 渐变与毛玻璃质感),适配综合性大学/职业院校门户的「鲜活」诉求。
- 全部颜色可在站点 theme_params 中微调;布局类参数仅 `galleryCols`(线上展厅每行列数 3/4/5)。
## 首页板块形态(栏目顺序即板块顺序)
| 栏目 homeStyle | 呈现 | 数据 |
| -- | -- | -- |
| (空) | 默认卡:featured 首条 + 行式列表 | categoryBlocks |
| `news_grid` | 双栏新闻卡:全幅背景(站点 config `news_bg_image`,未配走主题渐变)+ 半透明毛玻璃卡 + MM-DD 日期前缀双栏列表(16 条) | categoryBlocks |
| `media_focus` | 媒体聚焦:来源徽标(文章 `source` 字段,空则只显示标题)+ 标题双栏列表 | categoryBlocks |
| 独立板块 | 线上展厅画廊:双语文区头(config `gallery_title`/`gallery_subtitle`)+ 横向翻页轮播 | 轮播 position=`gallery`(设计尺寸 800×520) |
另有「要闻速递」横条(置顶文章无缝滚动,hover 暂停)与通栏轮播(1920×560)。
## 页脚能力(站点 config 驱动,全部有值才渲染)
`footer_addresses`(多校区地址,换行分行)· `qr_wechat`/`qr_weibo`(二维码,hover 放大)·
`social_wechat`/`social_weibo`/`social_douyin`/`social_bilibili`(社媒六边形图标阵列)·
`contact`/`copyright`/`beian`/logo/友链分组/校名底条(既有契约)· 返回顶部悬浮按钮(主题内置)。
## 参数取值建议
- 换主色:`primary`/`primaryDark`/`accent` 三者成对调整;浅色校徽建议保留页脚品牌底衬。
- 展厅图片比例接近 800×520 时观感最佳;不足一页时画廊自动隐藏翻页按钮且不轮播。
@@ -0,0 +1,402 @@
/* =====================================================================
campus-glow 炫彩校园(docs/12 展示型皮肤)
设计令牌:theme.json color 参数经服务端聚合 :root 覆盖(优先级 custom_css > themeVarsCss > 本文件)
===================================================================== */
:root {
--cg-primary: #0e8a5f;
--cg-primary-dark: #0a6244;
--cg-accent: #ff7a1a;
--cg-text: #2b2f33;
--cg-text-light: #7a8288;
--cg-bg: #f4f7f5;
--cg-card-bg: #ffffff;
--cg-border: #e3e8e4;
--cg-shadow: 0 10px 30px rgba(10, 98, 68, 0.10);
--cg-radius: 14px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
color: var(--cg-text);
background: var(--cg-bg);
line-height: 1.6;
}
img { max-width: 100%; border: 0; display: block; }
ul, ol, p, figure { margin: 0; padding: 0; }
li { list-style: none; }
a { color: inherit; text-decoration: none; transition: color .2s; }
button { font-family: inherit; cursor: pointer; }
.cg-container { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
.cg-skip-link {
position: absolute; left: -999px; top: 0; z-index: 300;
background: var(--cg-primary); color: #fff; padding: 8px 14px; border-radius: 0 0 8px 0;
}
.cg-skip-link:focus { left: 0; }
/* ===== 顶条 ===== */
.cg-topbar {
background: var(--cg-primary-dark);
color: rgba(255, 255, 255, .85);
font-size: 13px;
}
.cg-topbar-inner { display: flex; justify-content: space-between; align-items: center; min-height: 34px; }
.cg-colleges { position: relative; }
.cg-colleges-toggle { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; padding: 4px 0; }
.cg-colleges-arrow { font-size: 10px; transition: transform .2s; }
.cg-colleges-menu {
display: none; position: absolute; top: 100%; right: 0; z-index: 120;
min-width: 200px; max-height: 420px; overflow-y: auto;
background: #fff; color: var(--cg-text);
border: 1px solid var(--cg-border); border-top: 2px solid var(--cg-primary);
box-shadow: 0 6px 16px rgba(0, 0, 0, .12); padding: 6px 0;
}
.cg-colleges:hover .cg-colleges-menu, .cg-colleges:focus-within .cg-colleges-menu { display: block; }
.cg-colleges:hover .cg-colleges-arrow { transform: rotate(180deg); }
.cg-colleges-menu li a { display: block; padding: 7px 16px; font-size: 13px; }
.cg-colleges-menu li a:hover { background: color-mix(in srgb, var(--cg-primary) 8%, #fff); color: var(--cg-primary); }
.cg-colleges-menu li.active a { color: var(--cg-primary); font-weight: 600; }
/* ===== 品牌行 ===== */
.cg-brand-row { display: flex; justify-content: space-between; align-items: center; padding-top: 22px; padding-bottom: 22px; gap: 16px; flex-wrap: wrap; }
.cg-site-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
.cg-site-logo { max-height: 56px; max-width: 220px; width: auto; }
.cg-site-name { font-size: 26px; font-weight: 700; color: var(--cg-primary-dark); letter-spacing: 1px; }
.cg-site-search { display: flex; }
.cg-site-search input {
width: 240px; height: 38px; padding: 0 14px; font-size: 14px;
border: 2px solid var(--cg-primary); border-right: 0; border-radius: 19px 0 0 19px; outline: none;
background: #fff; color: var(--cg-text);
}
.cg-site-search button {
height: 38px; padding: 0 20px; border: 0; border-radius: 0 19px 19px 0;
background: var(--cg-primary); color: #fff; font-size: 14px;
}
.cg-site-search button:hover { background: var(--cg-primary-dark); }
/* ===== 栏目导航 ===== */
.cg-nav { background: linear-gradient(90deg, var(--cg-primary-dark), var(--cg-primary)); box-shadow: var(--cg-shadow); position: sticky; top: 0; z-index: 100; }
.cg-nav > ul { display: flex; flex-wrap: wrap; }
.cg-nav > ul > li { position: relative; }
.cg-nav > ul > li > a {
display: flex; align-items: center; gap: 3px; color: #fff; font-size: 16px;
padding: 13px 22px; position: relative;
}
.cg-nav > ul > li > a:hover, .cg-nav > ul > li.active > a { background: rgba(255, 255, 255, .14); }
.cg-nav > ul > li.active > a::after {
content: ""; position: absolute; left: 22px; right: 22px; bottom: 6px; height: 3px;
border-radius: 2px; background: var(--cg-accent);
}
.cg-caret { font-size: 10px; opacity: .8; }
.cg-sub-nav {
display: none; position: absolute; top: 100%; left: 0; z-index: 110; min-width: 176px;
background: #fff; border: 1px solid var(--cg-border); border-top: 2px solid var(--cg-accent);
box-shadow: 0 8px 18px rgba(0, 0, 0, .12); padding: 6px 0;
}
.cg-nav li:hover .cg-sub-nav, .cg-nav li:focus-within .cg-sub-nav { display: block; }
.cg-sub-nav a { display: block; padding: 8px 16px; font-size: 14px; color: var(--cg-text); }
.cg-sub-nav a:hover { background: color-mix(in srgb, var(--cg-primary) 8%, #fff); color: var(--cg-primary); }
/* ===== 轮播 ===== */
.cg-banner { position: relative; overflow: hidden; background: var(--cg-primary-dark); }
.cg-banner-slide { display: none; position: relative; }
.cg-banner-slide.active { display: block; }
.cg-banner-slide img { width: 100%; height: auto; aspect-ratio: 1920 / 560; object-fit: cover; }
.cg-banner-cap {
position: absolute; left: 0; right: 0; bottom: 0; padding: 42px 6% 20px;
color: #fff; font-size: 20px; font-weight: 600; text-shadow: 0 1px 4px rgba(0, 0, 0, .5);
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .45));
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cg-banner-arrow, .cg-gallery-arrow {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 5;
width: 42px; height: 42px; border: 0; border-radius: 50%;
background: rgba(0, 0, 0, .30); color: #fff; font-size: 24px; line-height: 1;
display: flex; align-items: center; justify-content: center;
transition: background .2s;
}
.cg-banner-arrow:hover, .cg-gallery-arrow:hover { background: var(--cg-accent); }
.cg-banner-arrow.prev, .cg-gallery-arrow.prev { left: 18px; }
.cg-banner-arrow.next, .cg-gallery-arrow.next { right: 18px; }
.cg-banner-dots { position: absolute; left: 0; right: 0; bottom: 14px; display: flex; justify-content: center; gap: 9px; z-index: 5; }
.cg-banner-dots button {
width: 10px; height: 10px; padding: 0; border: 0; border-radius: 50%;
background: rgba(255, 255, 255, .55);
}
.cg-banner-dots button.active { background: var(--cg-accent); width: 24px; border-radius: 5px; }
/* ===== 要闻速递横条 ===== */
.cg-ticker { background: var(--cg-card-bg); border-bottom: 1px solid var(--cg-border); }
.cg-ticker-inner { display: flex; align-items: center; gap: 14px; }
.cg-ticker-label {
flex: none; display: inline-flex; align-items: center; gap: 6px;
color: #fff; font-size: 14px; font-weight: 600; padding: 9px 16px;
background: linear-gradient(135deg, var(--cg-accent), #ff9f4d); border-radius: 0 0 12px 12px;
}
.cg-ticker-viewport { overflow: hidden; flex: 1; min-width: 0; }
.cg-ticker-track { display: inline-flex; white-space: nowrap; animation: cg-ticker-move 36s linear infinite; }
.cg-ticker:hover .cg-ticker-track { animation-play-state: paused; }
.cg-ticker-item { display: inline-flex; align-items: center; gap: 8px; padding: 10px 26px; font-size: 14px; color: var(--cg-text); }
.cg-ticker-item:hover { color: var(--cg-primary); }
.cg-ticker-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--cg-accent); flex: none; }
@keyframes cg-ticker-move { from { transform: translateX(0); } to { transform: translateX(-50%); } }
/* ===== 区块通用头 ===== */
.cg-sec-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.cg-sec-head h2 {
position: relative; font-size: 26px; font-weight: 700; color: var(--cg-primary-dark);
padding-left: 14px; letter-spacing: 1px;
}
.cg-sec-head h2::before {
content: ""; position: absolute; left: 0; top: 7px; bottom: 7px; width: 5px;
border-radius: 3px; background: linear-gradient(180deg, var(--cg-accent), var(--cg-primary));
}
.cg-more-circle {
flex: none; width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 2px solid var(--cg-primary); color: var(--cg-primary); background: transparent;
transition: all .2s;
}
.cg-more-circle:hover { background: var(--cg-primary); color: #fff; transform: translateX(3px); }
.cg-more-text { color: var(--cg-text-light); font-size: 14px; }
.cg-more-text:hover { color: var(--cg-primary); }
.cg-top-badge {
flex: none; font-size: 12px; color: #fff; background: var(--cg-accent);
border-radius: 4px; padding: 1px 7px; line-height: 1.6;
}
/* ===== 形态一:双栏新闻卡(全幅背景 + 毛玻璃) ===== */
.cg-newshot { position: relative; padding: 46px 0 52px; overflow: hidden; background: linear-gradient(135deg, var(--cg-primary-dark), var(--cg-primary) 55%, #35b98a); }
.cg-newshot-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
.cg-newshot-bg::after { content: ""; position: absolute; inset: 0; background: linear-gradient(120deg, rgba(10, 98, 68, .55), rgba(10, 98, 68, .18)); }
.cg-newshot .cg-container { position: relative; z-index: 2; }
.cg-newshot-card {
background: rgba(255, 255, 255, .88);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, .65); border-radius: var(--cg-radius);
box-shadow: var(--cg-shadow); padding: 30px 34px 26px;
}
.cg-newshot-card .cg-sec-head h2 { color: var(--cg-primary); }
.cg-news-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; row-gap: 2px; }
.cg-news-grid li { display: flex; align-items: center; gap: 12px; padding: 10px 2px; border-bottom: 1px dashed var(--cg-border); min-width: 0; }
.cg-news-grid li:nth-last-child(-n+2) { border-bottom: 0; }
.cg-news-date { flex: none; color: var(--cg-accent); font-size: 15px; font-weight: 600; letter-spacing: .5px; }
.cg-news-title { flex: 1; min-width: 0; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cg-news-title:hover { color: var(--cg-primary); }
/* ===== 形态二:媒体聚焦(来源徽标 + 标题) ===== */
.cg-media { padding: 46px 0 8px; }
.cg-media-card {
background: var(--cg-card-bg); border: 1px solid var(--cg-border);
border-radius: var(--cg-radius); box-shadow: var(--cg-shadow); padding: 30px 34px 26px;
}
.cg-media-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; }
.cg-media-grid li { display: flex; align-items: center; gap: 14px; padding: 13px 2px; border-bottom: 1px dashed var(--cg-border); min-width: 0; }
.cg-media-grid li:nth-last-child(-n+2) { border-bottom: 0; }
.cg-source-badge {
flex: none; min-width: 88px; max-width: 140px; text-align: center;
font-size: 13px; font-weight: 600; color: #fff; padding: 6px 10px;
background: linear-gradient(135deg, var(--cg-accent), #ff9f4d); border-radius: 8px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
box-shadow: 0 3px 8px rgba(255, 122, 26, .30);
}
.cg-media-title { flex: 1; min-width: 0; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cg-media-title:hover { color: var(--cg-primary); }
/* ===== 形态三:默认卡(featured + 行式列表) ===== */
.cg-block { padding: 40px 0 0; }
.cg-block-card {
background: var(--cg-card-bg); border: 1px solid var(--cg-border);
border-radius: var(--cg-radius); box-shadow: var(--cg-shadow); padding: 26px 30px;
}
.cg-block-cols { display: grid; grid-template-columns: 5fr 6fr; gap: 30px; }
.cg-block-featured { display: flex; gap: 16px; min-width: 0; }
.cg-feat-cover { flex: none; width: 200px; border-radius: 10px; overflow: hidden; }
.cg-feat-cover img { width: 100%; height: 140px; object-fit: cover; transition: transform .3s; }
.cg-block-featured:hover .cg-feat-cover img { transform: scale(1.05); }
.cg-feat-body { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.cg-feat-title { font-size: 16px; font-weight: 600; color: var(--cg-text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cg-block-featured:hover .cg-feat-title { color: var(--cg-primary); }
.cg-feat-summary { font-size: 13px; color: var(--cg-text-light); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.cg-feat-meta { margin-top: auto; font-size: 13px; color: var(--cg-text-light); }
.cg-block-list li { display: flex; align-items: center; gap: 12px; padding: 8px 2px; border-bottom: 1px dashed var(--cg-border); }
.cg-block-list li:last-child { border-bottom: 0; }
.cg-block-list li a { flex: 1; min-width: 0; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cg-block-list li a:hover { color: var(--cg-primary); }
.cg-block-list time { flex: none; font-size: 13px; color: var(--cg-text-light); }
/* ===== 线上展厅画廊 ===== */
.cg-gallery { padding: 52px 0 58px; }
.cg-gallery-head { text-align: center; margin-bottom: 26px; }
.cg-gallery-head h2 {
display: inline-block; position: relative; font-size: 30px; font-weight: 700;
color: var(--cg-accent); letter-spacing: 4px; padding: 0 74px;
}
.cg-gallery-head h2::before, .cg-gallery-head h2::after {
content: ""; position: absolute; top: 50%; width: 54px; height: 2px;
background: linear-gradient(90deg, transparent, var(--cg-accent));
}
.cg-gallery-head h2::before { left: 0; }
.cg-gallery-head h2::after { right: 0; transform: scaleX(-1); }
.cg-gallery-sub { color: var(--cg-text-light); font-size: 14px; letter-spacing: 2px; margin-top: 2px; text-transform: uppercase; }
.cg-gallery-stage { position: relative; overflow: hidden; border-radius: var(--cg-radius); }
.cg-gallery-track { display: flex; transition: transform .45s ease; }
.cg-gallery-item { position: relative; flex: 0 0 25%; display: block; padding: 0 8px; }
.cg-gallery-cols-3 .cg-gallery-item { flex-basis: 33.3333%; }
.cg-gallery-cols-4 .cg-gallery-item { flex-basis: 25%; }
.cg-gallery-cols-5 .cg-gallery-item { flex-basis: 20%; }
.cg-gallery-item img { width: 100%; height: 260px; object-fit: cover; border-radius: 10px; transition: transform .3s, box-shadow .3s; }
.cg-gallery-item:hover img { transform: translateY(-4px); box-shadow: 0 14px 28px rgba(10, 98, 68, .22); }
.cg-gallery-cap {
position: absolute; left: 8px; right: 8px; bottom: 0; padding: 26px 12px 9px;
color: #fff; font-size: 14px; border-radius: 0 0 10px 10px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .55));
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ===== 页脚(个性化) ===== */
.cg-footer {
position: relative; color: rgba(255, 255, 255, .88); margin-top: 46px;
background: linear-gradient(135deg, var(--cg-primary-dark) 0%, var(--cg-primary) 78%, #35b98a 100%);
}
.cg-footer::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(circle at 12% 20%, rgba(255, 255, 255, .10) 0, rgba(255, 255, 255, .10) 2px, transparent 3px),
radial-gradient(circle at 88% 30%, rgba(255, 255, 255, .08) 0, rgba(255, 255, 255, .08) 2px, transparent 3px);
background-size: 130px 130px, 170px 170px;
}
.cg-footer-main {
position: relative; display: grid; grid-template-columns: 1.1fr 1.6fr auto;
gap: 38px; padding-top: 40px; padding-bottom: 30px;
}
.cg-footer-logo-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.cg-footer-logo { max-height: 52px; max-width: 200px; width: auto; background: rgba(255, 255, 255, .92); border-radius: 8px; padding: 4px; }
.cg-footer-site { font-size: 20px; font-weight: 700; color: #fff; }
.cg-social { display: flex; gap: 12px; flex-wrap: wrap; }
.cg-social-icon {
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
color: #fff; background: rgba(255, 255, 255, .16);
clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
transition: background .2s, transform .2s;
}
.cg-social-icon:hover { background: var(--cg-accent); transform: translateY(-3px); }
.cg-footer-addr li { font-size: 14px; line-height: 2; }
.cg-footer-line { font-size: 14px; line-height: 2; }
.cg-footer-qr { display: flex; gap: 18px; }
.cg-qr { text-align: center; }
.cg-qr img {
width: 108px; height: 108px; object-fit: contain; background: #fff; border-radius: 8px; padding: 6px;
transition: transform .25s ease, box-shadow .25s ease; position: relative; z-index: 5;
}
.cg-qr:hover img { transform: scale(2.1); box-shadow: 0 12px 30px rgba(0, 0, 0, .35); }
.cg-qr figcaption { font-size: 13px; margin-top: 7px; color: rgba(255, 255, 255, .80); }
.cg-footer-links { position: relative; padding-bottom: 18px; }
.cg-links-group { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; padding: 6px 0; }
.cg-group-name { flex: none; font-size: 14px; font-weight: 600; color: #fff; }
.cg-links-row { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.cg-links-row a { font-size: 13px; color: rgba(255, 255, 255, .82); display: inline-flex; align-items: center; gap: 6px; }
.cg-links-row a:hover { color: #fff; }
.cg-link-logo { max-height: 22px; max-width: 60px; }
.cg-footer-bottom { position: relative; border-top: 1px solid rgba(255, 255, 255, .22); text-align: center; padding: 14px 16px 18px; font-size: 14px; letter-spacing: 1px; }
/* 返回顶部 */
.cg-backtop {
position: fixed; right: 26px; bottom: 90px; z-index: 150;
width: 46px; height: 46px; border: 0; border-radius: 50%;
background: var(--cg-primary); color: #fff; font-size: 20px;
box-shadow: 0 8px 20px rgba(10, 98, 68, .35);
opacity: 0; visibility: hidden; transform: translateY(14px);
transition: all .25s ease;
}
.cg-backtop.visible { opacity: 1; visibility: visible; transform: translateY(0); }
.cg-backtop:hover { background: var(--cg-accent); }
/* ===== 内页(列表/单页/详情共用) ===== */
.cg-list-main { padding: 26px 16px 46px; }
.cg-page-cols { display: grid; grid-template-columns: 232px 1fr; gap: 22px; align-items: start; }
.cg-side-nav { background: var(--cg-card-bg); border: 1px solid var(--cg-border); border-radius: var(--cg-radius); overflow: hidden; }
.cg-side-nav-head {
background: linear-gradient(135deg, var(--cg-primary-dark), var(--cg-primary));
color: #fff; font-size: 17px; font-weight: 600; padding: 13px 18px; letter-spacing: 1px;
}
.cg-side-nav-item a { display: block; padding: 11px 18px; font-size: 14px; border-left: 3px solid transparent; color: var(--cg-text); }
.cg-side-nav-item a:hover { background: color-mix(in srgb, var(--cg-primary) 7%, #fff); color: var(--cg-primary); }
.cg-side-nav-item.active a { border-left-color: var(--cg-accent); background: color-mix(in srgb, var(--cg-primary) 9%, #fff); color: var(--cg-primary); font-weight: 600; }
.cg-page-card { background: var(--cg-card-bg); border: 1px solid var(--cg-border); border-radius: var(--cg-radius); padding: 24px 28px 30px; min-width: 0; }
.cg-breadcrumb { font-size: 13px; color: var(--cg-text-light); margin-bottom: 8px; }
.cg-breadcrumb a:hover { color: var(--cg-primary); }
.cg-crumb-sep { margin: 0 8px; }
.cg-current { color: var(--cg-primary); font-weight: 600; }
.cg-page-title { font-size: 24px; font-weight: 700; color: var(--cg-primary-dark); margin: 6px 0 16px; padding-bottom: 12px; border-bottom: 2px solid var(--cg-border); position: relative; }
.cg-page-title::after { content: ""; position: absolute; left: 0; bottom: -2px; width: 64px; height: 2px; background: var(--cg-accent); }
.cg-article-list li { border-bottom: 1px dashed var(--cg-border); }
.cg-article-list li:last-child { border-bottom: 0; }
.cg-article-item { display: flex; gap: 18px; padding: 16px 2px; }
.cg-item-cover { flex: none; width: 190px; border-radius: 10px; overflow: hidden; }
.cg-item-cover img { width: 100%; height: 122px; object-fit: cover; transition: transform .3s; }
.cg-article-item:hover .cg-item-cover img { transform: scale(1.05); }
.cg-item-body { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.cg-item-title a { font-size: 16px; font-weight: 600; display: block; }
.cg-item-title a:hover { color: var(--cg-primary); }
.cg-item-summary { font-size: 13px; color: var(--cg-text-light); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cg-item-meta { margin-top: auto; display: flex; align-items: center; gap: 16px; font-size: 13px; color: var(--cg-text-light); }
.cg-pagination { display: flex; justify-content: center; gap: 8px; margin-top: 22px; flex-wrap: wrap; }
.cg-pagination a { min-width: 34px; height: 34px; padding: 0 10px; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; border: 1px solid var(--cg-border); border-radius: 17px; color: var(--cg-text); background: var(--cg-card-bg); }
.cg-pagination a:hover { border-color: var(--cg-primary); color: var(--cg-primary); }
.cg-pagination a.active { background: var(--cg-primary); border-color: var(--cg-primary); color: #fff; }
.cg-pagination a.disabled { opacity: .45; pointer-events: none; }
.cg-article-title { font-size: 24px; font-weight: 700; color: var(--cg-text); line-height: 1.5; margin: 10px 0 8px; }
.cg-article-meta { display: flex; flex-wrap: wrap; gap: 6px 20px; font-size: 13px; color: var(--cg-text-light); padding-bottom: 16px; border-bottom: 1px solid var(--cg-border); }
.cg-article-content { padding-top: 18px; font-size: 15px; line-height: 1.9; word-break: break-word; }
.cg-article-content img { max-width: 100%; height: auto; margin: 8px auto; border-radius: 8px; }
.cg-article-content p { margin: 0 0 12px; }
.cg-article-sibling { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 26px; }
.cg-article-sibling .prev, .cg-article-sibling .next { border: 1px solid var(--cg-border); border-radius: 10px; padding: 12px 16px; display: block; min-width: 0; }
.cg-article-sibling .next { text-align: right; }
.cg-article-sibling a:hover { border-color: var(--cg-primary); }
.cg-sib-label { display: block; font-size: 12px; color: var(--cg-text-light); margin-bottom: 4px; }
.cg-sib-title { display: block; font-size: 14px; color: var(--cg-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ===== 空态 ===== */
.cg-empty { text-align: center; padding: 44px 0; color: var(--cg-text-light); }
.cg-empty-hint { font-size: 13px; margin-top: 6px; opacity: .8; }
/* ===== 响应式 ===== */
@media (max-width: 992px) {
.cg-gallery-item, .cg-gallery-cols-3 .cg-gallery-item, .cg-gallery-cols-4 .cg-gallery-item, .cg-gallery-cols-5 .cg-gallery-item { flex-basis: 50%; }
.cg-gallery-item img { height: 210px; }
.cg-footer-main { grid-template-columns: 1fr 1fr; }
.cg-footer-qr { grid-column: 1 / -1; }
.cg-block-cols { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.cg-site-name { font-size: 20px; }
.cg-site-search input { width: 150px; }
.cg-nav > ul > li > a { padding: 11px 13px; font-size: 14px; }
.cg-banner-slide img { aspect-ratio: 16 / 9; }
.cg-banner-cap { font-size: 15px; padding: 30px 5% 14px; }
.cg-ticker-label { display: none; }
.cg-news-grid, .cg-media-grid { grid-template-columns: 1fr; }
.cg-news-grid li:nth-last-child(-n+2), .cg-media-grid li:nth-last-child(-n+2) { border-bottom: 1px dashed var(--cg-border); }
.cg-news-grid li:last-child, .cg-media-grid li:last-child { border-bottom: 0; }
.cg-newshot-card, .cg-media-card, .cg-block-card { padding: 20px 16px; }
.cg-sec-head h2 { font-size: 21px; }
.cg-page-cols { grid-template-columns: 1fr; }
.cg-article-item { flex-direction: column; }
.cg-item-cover { width: 100%; }
.cg-item-cover img { height: 170px; }
.cg-article-sibling { grid-template-columns: 1fr; }
.cg-footer-main { grid-template-columns: 1fr; gap: 24px; }
.cg-backtop { right: 14px; bottom: 70px; width: 40px; height: 40px; }
}
@media (max-width: 640px) {
.cg-gallery-item, .cg-gallery-cols-3 .cg-gallery-item, .cg-gallery-cols-4 .cg-gallery-item, .cg-gallery-cols-5 .cg-gallery-item { flex-basis: 100%; }
.cg-gallery-item img { height: 230px; }
.cg-qr:hover img { transform: scale(1.6); }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

@@ -0,0 +1,117 @@
/* campus-glow 皮肤包脚本:顶条日期 + 首页轮播 + 线上展厅画廊翻页 + 返回顶部,无任何外部依赖 */
(function () {
/* 顶条日期(本地时间,YYYY年M月D日 星期X) */
var dateEl = document.getElementById('siteDate');
if (dateEl) {
var now = new Date();
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
+ now.getDate() + '日 星期' + week;
}
/* 首页轮播 */
(function () {
var root = document.querySelector('.cg-banner');
if (!root) return;
var slides = root.querySelectorAll('.cg-banner-slide');
var dots = root.querySelectorAll('.cg-banner-dots button');
var prevBtn = root.querySelector('.cg-banner-arrow.prev');
var nextBtn = root.querySelector('.cg-banner-arrow.next');
if (!slides.length) 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);
start();
})();
/* 线上展厅画廊:按页翻动(每页 = 当前列数),到尾页回卷 */
(function () {
var stage = document.querySelector('.cg-gallery-stage');
if (!stage) return;
var track = stage.querySelector('.cg-gallery-track');
var items = track ? track.children : [];
var prevBtn = stage.querySelector('.cg-gallery-arrow.prev');
var nextBtn = stage.querySelector('.cg-gallery-arrow.next');
if (!track || items.length < 1) return;
var page = 0;
var timer = null;
var INTERVAL = 4500;
function perView() {
var w = window.innerWidth;
if (w <= 640) return 1;
if (w <= 992) return 2;
return parseInt(stage.getAttribute('data-cols') || '4', 10) || 4;
}
function pages() {
return Math.max(1, Math.ceil(items.length / perView()));
}
function update() {
if (page >= pages()) page = 0;
if (page < 0) page = pages() - 1;
track.style.transform = 'translateX(-' + (page * 100) + '%)';
}
function start() {
stop();
if (pages() > 1) {
timer = window.setInterval(function () { page++; update(); }, INTERVAL);
}
}
function go(delta) { page += delta; update(); start(); }
function stop() {
if (timer) { window.clearInterval(timer); timer = null; }
}
if (prevBtn) prevBtn.addEventListener('click', function () { go(-1); });
if (nextBtn) nextBtn.addEventListener('click', function () { go(1); });
stage.addEventListener('mouseenter', stop);
stage.addEventListener('mouseleave', start);
window.addEventListener('resize', function () { page = 0; update(); });
update();
start();
})();
/* 返回顶部:滚动一屏后出现,点击平滑回顶 */
(function () {
var btn = document.querySelector('.cg-backtop');
if (!btn) return;
function toggle() {
if (window.scrollY > window.innerHeight * 0.6) {
btn.classList.add('visible');
} else {
btn.classList.remove('visible');
}
}
window.addEventListener('scroll', toggle, { passive: true });
btn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
toggle();
})();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 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="cg-list-main cg-container" id="main-content">
<div class="cg-page-cols">
<!-- 左侧栏目导航(docs/11 侧栏契约):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="cg-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="cg-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="cg-side-nav-list">
<li class="cg-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="cg-page-card">
<nav class="cg-breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="cg-crumb-sep" aria-hidden="true">/</span>
<a class="cg-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="cg-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="cg-current">正文</span>
</nav>
<article class="cg-article">
<h1 class="cg-article-title" th:text="${article.title}">文章标题</h1>
<div class="cg-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="cg-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="cg-article-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}">
<span class="cg-sib-label">上一篇</span>
<span class="cg-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="cg-sib-none prev" th:if="${prevArticle == null}">
<span class="cg-sib-label">上一篇</span>
<span class="cg-sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}">
<span class="cg-sib-label">下一篇</span>
<span class="cg-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="cg-sib-none next" th:if="${nextArticle == null}">
<span class="cg-sib-label">下一篇</span>
<span class="cg-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,99 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚(docs/12 个性化页脚):品牌区(logo+站名+社媒图标阵列) + 多校区地址 + 二维码(hover 放大)
+ 联系/版权/备案 + 友链分组 + 底条校名 + 返回顶部。
基础数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName);
新增消费 config 键:footer_addresses(经 site.footerAddressLines 派生)/qr_wechat/qr_weibo/
social_wechat/social_weibo/social_douyin/social_bilibili(均有值才渲染,空站点优雅降级)。 -->
<footer class="cg-footer" th:fragment="footer">
<div class="cg-container cg-footer-main">
<!-- 品牌区 -->
<div class="cg-footer-brand">
<div class="cg-footer-logo-row">
<img th:if="${site.config['logo'] != null}" class="cg-footer-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<span class="cg-footer-site" th:text="${site.name}">站点名称</span>
</div>
<div class="cg-social" th:if="${site.config['social_wechat'] != null
or site.config['social_weibo'] != null
or site.config['social_douyin'] != null
or site.config['social_bilibili'] != null}">
<a th:if="${site.config['social_wechat'] != null}" class="cg-social-icon"
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
<svg viewBox="0 0 24 24" width="22" height="22" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/>
</svg>
</a>
<a th:if="${site.config['social_weibo'] != null}" class="cg-social-icon"
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
<svg viewBox="0 0 24 24" width="22" height="22" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/>
</svg>
</a>
<a th:if="${site.config['social_douyin'] != null}" class="cg-social-icon"
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
<svg viewBox="0 0 24 24" width="22" height="22" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/>
</svg>
</a>
<a th:if="${site.config['social_bilibili'] != null}" class="cg-social-icon"
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
<svg viewBox="0 0 24 24" width="22" height="22" focusable="false" aria-hidden="true">
<path fill="currentColor" d="M7.2 4.3 9 6.4h6L16.8 4.3c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/>
</svg>
</a>
</div>
</div>
<!-- 地址/联系区 -->
<div class="cg-footer-info">
<ul class="cg-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
</ul>
<p class="cg-footer-line" th:if="${site.config['contact'] != null}">
<span th:text="${site.config['contact']}">联系方式</span>
</p>
<p class="cg-footer-line" th:if="${site.config['copyright'] != null}">
<span th:text="${site.config['copyright']}">版权信息</span>
</p>
<p class="cg-footer-line" th:if="${site.config['beian'] != null}">
<span th:text="${site.config['beian']}">备案号</span>
</p>
</div>
<!-- 二维码区(hover 放大预览) -->
<div class="cg-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
<figure class="cg-qr" th:if="${site.config['qr_wechat'] != null}">
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
<figcaption>微信扫码</figcaption>
</figure>
<figure class="cg-qr" th:if="${site.config['qr_weibo'] != null}">
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
<figcaption>微博扫码</figcaption>
</figure>
</div>
</div>
<!-- 友链分组(无分组整栏不渲染) -->
<div class="cg-container cg-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="cg-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="cg-group-name" th:text="${group.groupName}">分组</span>
<p class="cg-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="cg-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 class="cg-footer-bottom">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
<button class="cg-backtop" type="button" aria-label="返回顶部" th:title="返回顶部">↑</button>
</footer>
</html>
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- 公共 head 片段:契约与 default 一致(SEO + 主题资产 + 变量/自定义样式注入),仅换皮 -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<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')}">
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
</html>
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:顶条(日期+专业院系) + 品牌行(校徽/站名/搜索) + 栏目导航(数据契约与 default 一致,仅换皮) -->
<header class="cg-header" th:fragment="header">
<a class="cg-skip-link" href="#main-content">跳到主要内容</a>
<div class="cg-topbar">
<div class="cg-container cg-topbar-inner">
<span class="cg-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="cg-topbar-side" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<div class="cg-colleges">
<span class="cg-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="cg-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="cg-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="cg-container cg-brand-row">
<div class="cg-site-brand">
<img th:if="${site.config['logo'] != null}" class="cg-site-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="cg-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="cg-site-search" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
<nav class="cg-nav" aria-label="栏目导航">
<ul class="cg-container">
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == 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="cg-caret" aria-hidden="true">▾</i></a>
<ul class="cg-sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,176 @@
<!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="cg-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize) -->
<section class="cg-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="cg-banner-slides">
<a class="cg-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="cg-banner-cap" th:text="${b.title}">标题</span>
</a>
</div>
<button class="cg-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="cg-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="cg-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>
<!-- 要闻速递横条:置顶文章横向无缝滚动(hover 暂停) -->
<section class="cg-ticker" th:if="${!#lists.isEmpty(topArticles)}" aria-label="要闻速递">
<div class="cg-container cg-ticker-inner">
<span class="cg-ticker-label">要闻速递</span>
<div class="cg-ticker-viewport">
<div class="cg-ticker-track">
<a class="cg-ticker-item" th:each="item : ${topArticles}" th:href="${url.article(item.id)}">
<span class="cg-ticker-dot" aria-hidden="true"></span>
<span th:text="${item.title}">要闻标题</span>
</a>
<a class="cg-ticker-item" th:each="item : ${topArticles}" th:href="${url.article(item.id)}" aria-hidden="true">
<span class="cg-ticker-dot" aria-hidden="true"></span>
<span th:text="${item.title}">要闻标题</span>
</a>
</div>
</div>
</div>
</section>
<!-- 一级栏目区块(docs/12):按栏目 homeStyle 三种形态渲染,栏目顺序即首页板块顺序 -->
<th:block th:each="block : ${categoryBlocks}">
<!-- 形态一:双栏新闻卡(homeStyle=news_grid)——全幅背景 + 毛玻璃卡 + 双栏日期列表 -->
<section class="cg-newshot" th:if="${block.homeStyle == 'news_grid'}"
th:with="newsBg=${site.config['news_bg_image']}" aria-label="栏目区块">
<div class="cg-newshot-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="cg-container">
<div class="cg-newshot-card">
<div class="cg-sec-head">
<h2 th:text="${block.category.name}">校园时讯</h2>
<a class="cg-more-circle" th:href="${block.moreUrl}" aria-label="更多">
<svg viewBox="0 0 24 24" width="18" height="18" focusable="false" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/>
</svg>
</a>
</div>
<ul class="cg-news-grid" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<span class="cg-news-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">09-09</span>
<a class="cg-news-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="cg-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="cg-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="cg-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
<!-- 形态二:媒体聚焦列表(homeStyle=media_focus)——来源徽标 + 标题双栏 -->
<section class="cg-media" th:if="${block.homeStyle == 'media_focus'}" aria-label="栏目区块">
<div class="cg-container">
<div class="cg-media-card">
<div class="cg-sec-head">
<h2 th:text="${block.category.name}">媒体聚焦</h2>
<a class="cg-more-circle" th:href="${block.moreUrl}" aria-label="更多">
<svg viewBox="0 0 24 24" width="18" height="18" focusable="false" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/>
</svg>
</a>
</div>
<ul class="cg-media-grid" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<span class="cg-source-badge" th:if="${!#strings.isEmpty(item.source)}"
th:text="${item.source}">媒体来源</span>
<a class="cg-media-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="cg-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="cg-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="cg-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
<!-- 形态三:默认卡(homeStyle 空)——featured 首条 + 行式列表,旧栏目保持常规呈现 -->
<section class="cg-block" th:if="${block.homeStyle == null or block.homeStyle == ''}" aria-label="栏目区块">
<div class="cg-container">
<div class="cg-block-card">
<div class="cg-sec-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="cg-more-text" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<div class="cg-block-cols" th:if="${!#lists.isEmpty(block.items)}">
<a class="cg-block-featured" th:with="feat=${block.items[0]}" th:href="${url.article(feat.id)}">
<span class="cg-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="cg-feat-body">
<span class="cg-feat-title" th:text="${feat.title}">头条标题</span>
<span class="cg-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="cg-feat-meta">
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
<ul class="cg-block-list">
<li th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<div class="cg-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="cg-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
</th:block>
<!-- 线上展厅(cms_banner position=gallery;设计尺寸 800×520;每行列数由主题参数 galleryCols 控制) -->
<section class="cg-gallery" th:if="${!#lists.isEmpty(galleryBanners)}" aria-label="线上展厅">
<div class="cg-container">
<div class="cg-gallery-head">
<h2 th:text="${site.config['gallery_title'] ?: '线上展厅'}">线上展厅</h2>
<p class="cg-gallery-sub" th:text="${site.config['gallery_subtitle'] ?: 'Online Exhibition Hall'}">Online Exhibition Hall</p>
</div>
<div class="cg-gallery-stage"
th:classappend="${'cg-gallery-cols-' + (themeParams['galleryCols'] ?: '4')}"
th:attr="data-cols=${themeParams['galleryCols'] ?: '4'}">
<div class="cg-gallery-track">
<a class="cg-gallery-item" th:each="g : ${galleryBanners}" th:href="${g.linkUrl}"
th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}">
<span class="cg-gallery-cap" th:if="${!#strings.isEmpty(g.title)}"
th:text="${g.title}">作品标题</span>
</a>
</div>
<button class="cg-gallery-arrow prev" type="button" aria-label="上一页" th:if="${#lists.size(galleryBanners) > 1}">‹</button>
<button class="cg-gallery-arrow next" type="button" aria-label="下一页" th:if="${#lists.size(galleryBanners) > 1}">›</button>
</div>
</div>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,107 @@
<!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="cg-list-main cg-container" id="main-content">
<div class="cg-page-cols">
<!-- 左侧栏目导航(docs/11 侧栏契约):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="cg-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="cg-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="cg-side-nav-list">
<li class="cg-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="cg-page-card">
<nav class="cg-breadcrumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="cg-crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="cg-crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="cg-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="cg-breadcrumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="cg-crumb-sep" aria-hidden="true">/</span>
<span class="cg-current">搜索结果</span>
</nav>
<h1 class="cg-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="cg-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="cg-article-list">
<!-- 有封面:图文行;无封面:文字行(与图文行同等用心,不造占位图) -->
<li th:each="item : ${listData.items}">
<div class="cg-article-item" th:if="${item.coverImage != null}">
<a class="cg-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="cg-item-body">
<div class="cg-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="cg-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="cg-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:if="${!#strings.isEmpty(item.source)}"
th:text="'来源:' + ${item.source}">来源</span>
<span class="cg-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="cg-article-item cg-plain" th:if="${item.coverImage == null}">
<div class="cg-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="cg-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="cg-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:if="${!#strings.isEmpty(item.source)}"
th:text="'来源:' + ${item.source}">来源</span>
<span class="cg-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</li>
</ul>
<div class="cg-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="cg-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="cg-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<nav class="cg-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="cg-list-main cg-container" id="main-content">
<div class="cg-page-cols">
<!-- 左侧栏目导航(docs/11 侧栏契约):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="cg-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="cg-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="cg-side-nav-list">
<li class="cg-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="cg-page-card">
<nav class="cg-breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="cg-crumb-sep" aria-hidden="true">/</span>
<a class="cg-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="cg-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="cg-current">正文</span>
</nav>
<article class="cg-article">
<h1 class="cg-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="cg-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="cg-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="cg-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,27 @@
{
"specVersion": 1,
"name": "campus-glow",
"title": "炫彩校园",
"version": "1.0.0",
"author": "yunhai",
"description": "展示型皮肤:双栏新闻卡、媒体聚焦来源标签、线上展厅画廊、个性化页脚与返回顶部,首页更炫(docs/12)。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--cg-primary", "label": "主色", "type": "color", "default": "#0e8a5f" },
{ "key": "primaryDark", "cssVar": "--cg-primary-dark", "label": "主色加深", "type": "color", "default": "#0a6244" },
{ "key": "accent", "cssVar": "--cg-accent", "label": "强调色", "type": "color", "default": "#ff7a1a" },
{ "key": "text", "cssVar": "--cg-text", "label": "正文文字色", "type": "color", "default": "#2b2f33" },
{ "key": "textLight", "cssVar": "--cg-text-light", "label": "次要文字色", "type": "color", "default": "#7a8288" },
{ "key": "bg", "cssVar": "--cg-bg", "label": "页面底色", "type": "color", "default": "#f4f7f5" },
{ "key": "cardBg", "cssVar": "--cg-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--cg-border", "label": "边框色", "type": "color", "default": "#e3e8e4" },
{ "key": "galleryCols", "label": "展厅每行列数", "type": "select", "default": "4",
"options": [
{ "value": "3", "label": "3 列" },
{ "value": "4", "label": "4 列" },
{ "value": "5", "label": "5 列" }
]
}
]
}
@@ -344,3 +344,29 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
margin-right: 4px;
border-radius: 2px;
}
/* ===== 左侧栏目导航(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; }
}
@@ -4,10 +4,25 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</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>
@@ -35,6 +50,7 @@
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -34,14 +34,15 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
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}">
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="list-main">
<main class="list-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" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
<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>
@@ -42,6 +59,7 @@
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<span th:text="${category.name}">栏目</span>
<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">
@@ -16,6 +33,7 @@
<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>
@@ -946,3 +946,51 @@ time, .item-meta { color: var(--text-muted); font-size: var(--fs-sm); }
margin-right: 4px;
border-radius: 2px;
}
/* ===== 左侧栏目导航(docs/11):有二级节点的模块,栏目页/详情页左侧展示兄弟目录 ===== */
.page-cols { display: flex; align-items: flex-start; gap: var(--sp-6); }
.page-cols .page-card { flex: 1; min-width: 0; }
.side-nav {
flex: none;
width: 220px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.side-nav-head {
background: var(--primary);
color: var(--on-dark, #fff);
font-size: var(--fs-lg);
font-weight: 700;
text-align: center;
padding: var(--sp-5) var(--sp-3);
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: var(--sp-4, 12px) var(--sp-5, 20px);
color: var(--text);
text-decoration: none;
font-size: var(--fs-md);
transition: background .15s ease, color .15s ease;
}
.side-nav-item a:hover { color: var(--primary); background: var(--surface-variant, transparent); }
.side-nav-item.active a { background: var(--primary); color: var(--on-dark, #fff); font-weight: 600; text-decoration: none; }
/* 顶导航二级下拉当前项高亮(docs/11 顺带) */
.site-nav ul.sub-nav li.active a { color: var(--primary); }
@media (max-width: 768px) {
.page-cols { flex-direction: column; gap: var(--sp-3); }
.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; padding: var(--sp-3) var(--sp-4); }
}
@media print {
.side-nav { display: none; }
.page-cols { display: block; }
}
@@ -5,15 +5,30 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main container" id="main-content">
<div class="page-card">
<nav class="breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}"
th:text="${currentCategoryName}">栏目</a>
<span class="crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="current">正文</span>
</nav>
<div class="page-cols">
<!-- 左侧栏目导航(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="page-card">
<nav class="breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="current">正文</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${article.title}">文章标题</h1>
@@ -51,6 +66,7 @@
<span class="sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
@@ -47,13 +47,14 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:悬停展开(.sub-nav 样式见 assets/css/main.css) -->
<!-- 二级下拉:悬停展开(.sub-nav 样式见 assets/css/main.css);当前项高亮(docs/11) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<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>
@@ -5,29 +5,60 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="list-main container" id="main-content">
<div class="page-card">
<nav class="breadcrumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current" th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current">搜索结果</span>
</nav>
<div class="page-cols">
<!-- 左侧栏目导航(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>
<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>
<div class="page-card">
<nav class="breadcrumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="breadcrumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current">搜索结果</span>
</nav>
<ul class="article-list">
<!-- 有封面:图文行;无封面:文字行(与图文行同等用心,不造占位图) -->
<li th:each="item : ${listData.items}">
<div class="article-item" th:if="${item.coverImage != null}">
<a class="item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="item-body">
<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}">
<div class="article-item" th:if="${item.coverImage != null}">
<a class="item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="item-body">
<div class="item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<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>
</div>
</div>
<div class="article-item plain" th:if="${item.coverImage == null}">
<div class="item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
@@ -39,43 +70,31 @@
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="article-item plain" th:if="${item.coverImage == null}">
<div class="item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<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>
</div>
</li>
</ul>
<div class="empty-state" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
</li>
</ul>
<div class="empty-state" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="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>
<!-- 分页(列表页/搜索页共用) -->
<nav class="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>
@@ -5,23 +5,42 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main container" id="main-content">
<div class="page-card">
<nav class="breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="crumb-sep" aria-hidden="true">/</span>
<span class="current" th:text="${category.name}">栏目</span>
</nav>
<div class="page-cols">
<!-- 左侧栏目导航(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>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="empty-state" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="empty-hint">内容编辑后将在此展示</p>
</div>
</article>
<div class="page-card">
<nav class="breadcrumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="current">正文</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="empty-state" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
@@ -1,4 +1,4 @@
# fresh-mint2 皮肤包
# fresh-mint 皮肤包
由 blue-white 派生的清新薄荷风皮肤(**卡片封面版**,docs/10 契约 §5 工作流)。**升级随版本发布,不在站点间复制目录**。
@@ -12,7 +12,7 @@
| 页面 | 属性 |
| --- | --- |
| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/upload/asset/search/siteHome)、`themeBase`(=`themes/fresh-mint2`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) |
| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/upload/asset/search/siteHome)、`themeBase`(=`themes/fresh-mint`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) |
| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) |
| list / search | `listData`(分页 + `items[].coverImage`)、`category`(list)、`q` + `searchMode`(search) |
| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` |
@@ -1,5 +1,5 @@
/* ============================================================
* fresh-mint2 皮肤包样式(blue-white 派生,docs/10 皮肤包契约 §5)
* fresh-mint 皮肤包样式(blue-white 派生,docs/10 皮肤包契约 §5)
* 清新薄荷风(卡片封面版):主色 #17A2A2、浅薄荷底、白卡 12px 圆角 + 柔和阴影;
* 细线吸顶顶栏 + 居中 logo;列表为带封面的卡片网格(一行 3 张);几何圆点装饰。
* 无纹理、无通栏大图、无复杂动效。
@@ -622,3 +622,29 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
}
.site-nav .sub-nav li a { color: var(--text); padding: 8px 14px; }
}
/* ===== 左侧栏目导航(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; }
}

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

@@ -1,4 +1,4 @@
/* fresh-mint2 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */
/* fresh-mint 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */
(function () {
var slides = document.querySelectorAll('.banner-slide');
var dots = document.querySelectorAll('.banner-dots button');

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -4,10 +4,25 @@
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</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>
@@ -35,6 +50,7 @@
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -41,14 +41,15 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
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}">
<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>
@@ -4,10 +4,27 @@
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="list-main">
<main class="list-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" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
<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>
@@ -50,6 +67,7 @@
<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>
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<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,7 +1,7 @@
{
"specVersion": 1,
"name": "fresh-mint2",
"title": "清新薄荷·II",
"name": "fresh-mint",
"title": "清新薄荷",
"version": "1.0.0",
"author": "yunhai",
"description": "清新薄荷风(卡片封面版):主色 #17A2A2、浅薄荷底、白卡 12px 圆角;细线吸顶顶栏 + 居中 logo;列表为带封面的卡片网格(一行 3 张);几何圆点装饰与大留白;适合职业院校。",
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</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>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -588,3 +588,29 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
.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; }
}
@@ -4,10 +4,25 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</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>
@@ -35,6 +50,7 @@
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -35,14 +35,15 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
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}">
<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>
@@ -5,10 +5,27 @@
<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'">
<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>
<span th:text="${category.name}">栏目</span>
<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>
@@ -43,6 +60,7 @@
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<span th:text="${category.name}">栏目</span>
<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">
@@ -16,6 +33,7 @@
<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>
@@ -1305,3 +1305,57 @@ input, button, select, textarea { font-size: inherit; color: inherit; }
margin-right: 4px;
border-radius: 2px;
}
/* ===== 左侧栏目导航(docs/11):有二级节点的模块,栏目/文章页左侧展示兄弟目录 ===== */
.sp-cols { display: flex; align-items: flex-start; gap: 28px; }
.sp-cols .sp-page-card { flex: 1; min-width: 0; }
.sp-side-nav {
flex: none;
width: 230px;
background: var(--sp-card-bg);
border: 1px solid var(--sp-border);
border-top: 3px solid var(--sp-primary);
box-shadow: var(--sp-shadow-md);
}
.sp-side-nav-head {
background: var(--sp-primary);
color: #fff;
font-size: 20px;
font-weight: 700;
text-align: center;
padding: 18px 12px;
letter-spacing: 3px;
}
.sp-side-nav-list { list-style: none; margin: 0; padding: 0; }
.sp-side-nav-item + .sp-side-nav-item { border-top: 1px solid var(--sp-border); }
.sp-side-nav-item a {
display: block;
padding: 13px 18px;
color: var(--sp-text);
text-decoration: none;
font-size: 15px;
border-left: 3px solid transparent;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.sp-side-nav-item a:hover { color: var(--sp-primary); background: var(--sp-panel); }
.sp-side-nav-item.active a {
background: var(--sp-primary);
color: #fff;
font-weight: 600;
text-decoration: none;
border-left-color: var(--sp-primary-light);
}
/* 顶导航二级下拉当前项高亮(docs/11 顺带) */
.sp-nav-list ul.sp-sub li.active a { color: var(--sp-primary-light); }
@media (max-width: 768px) {
.sp-cols { flex-direction: column; gap: 16px; }
.sp-side-nav { width: 100%; }
.sp-side-nav-list { display: flex; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.sp-side-nav-item { flex: none; }
.sp-side-nav-item + .sp-side-nav-item { border-top: none; border-left: 1px solid var(--sp-border); }
.sp-side-nav-item a { white-space: nowrap; border-left: none; }
}
@media print {
.sp-side-nav { display: none; }
.sp-cols { display: block; }
}
@@ -5,12 +5,27 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="sp-page sp-container" id="main-content">
<div class="sp-page-card">
<div class="sp-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="sp-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="sp-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="sp-side-nav-list">
<li class="sp-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="sp-page-card">
<nav class="sp-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}"
th:text="${currentCategoryName}">栏目</a>
<th:block th:each="node : ${categoryPath}">
<span class="sp-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="sp-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="sp-crumb-current">正文</span>
</nav>
@@ -51,6 +66,7 @@
<span class="sp-sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
@@ -65,13 +65,14 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
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="sp-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:主蓝半透明面板,顶部亮线,项 hover 加深+左亮条 -->
<ul class="sp-sub" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<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>
@@ -5,12 +5,30 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="sp-page sp-container" id="main-content">
<div class="sp-page-card">
<nav class="sp-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
<span class="sp-crumb-current" th:text="${category.name}">栏目</span>
</nav>
<div class="sp-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="sp-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="sp-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="sp-side-nav-list">
<li class="sp-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="sp-page-card">
<nav class="sp-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="sp-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="sp-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="sp-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
@@ -77,6 +95,7 @@
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>
@@ -5,11 +5,29 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="sp-page sp-container" id="main-content">
<div class="sp-page-card">
<div class="sp-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="sp-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="sp-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="sp-side-nav-list">
<li class="sp-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="sp-page-card">
<nav class="sp-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
<span class="sp-crumb-current" th:text="${category.name}">栏目</span>
<th:block th:each="node, st : ${categoryPath}">
<span class="sp-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="sp-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<article class="sp-article">
@@ -22,6 +40,7 @@
<p class="sp-empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
@@ -342,3 +342,29 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
margin-right: 4px;
border-radius: 2px;
}
/* ===== 左侧栏目导航(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; }
}
@@ -4,10 +4,25 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</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>
@@ -35,6 +50,7 @@
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -33,14 +33,15 @@
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
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}">
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="list-main">
<main class="list-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" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
<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>
@@ -42,6 +59,7 @@
<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>
@@ -4,10 +4,27 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<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>
<span th:text="${category.name}">栏目</span>
<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">
@@ -16,6 +33,7 @@
<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>