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
@@ -0,0 +1,41 @@
|
||||
-- ============================================================
|
||||
-- bsch_cms 全量清库脚本(清测试数据,全流程重走)
|
||||
-- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root)
|
||||
-- 范围:cms_* 六表全空(站点/栏目/文章/轮播/友链/附件),AUTO_INCREMENT 归位
|
||||
-- 不动的东西:
|
||||
-- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库)
|
||||
-- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录)
|
||||
-- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明)
|
||||
-- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。
|
||||
-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"六表为空",本脚本正好满足)。
|
||||
-- ============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
TRUNCATE TABLE cms_attachment;
|
||||
TRUNCATE TABLE cms_banner;
|
||||
TRUNCATE TABLE cms_link;
|
||||
TRUNCATE TABLE cms_article;
|
||||
TRUNCATE TABLE cms_category;
|
||||
TRUNCATE TABLE cms_site;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 验证:六表应全为 0
|
||||
SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site
|
||||
UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category
|
||||
UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article
|
||||
UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner
|
||||
UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link
|
||||
UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 可选(不在 SQL 内):清掉测试期上传的物理文件,只留种子静态资产。
|
||||
-- 保留:D:/bsch/cms/upload/seed/ (cms_seed_local.sql 的配图依赖它)
|
||||
-- 删除:seed/ 之外的目录与散文件(测试期真实上传的图片/PDF 均为孤儿)
|
||||
-- Git Bash 下参考:
|
||||
-- find /d/bsch/cms/upload -mindepth 1 -maxdepth 1 ! -name seed -exec rm -rf {} +
|
||||
-- 主题包如需一并重置(连派生皮肤一起清掉,回到内置包):
|
||||
-- rm -rf <服务运行目录>/data/themes (重启服务后仅剩 classpath 内置包)
|
||||
-- ------------------------------------------------------------
|
||||
@@ -26,7 +26,7 @@ public class AttachmentController {
|
||||
|
||||
private final AttachmentService attachmentService;
|
||||
|
||||
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */
|
||||
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site/category(category=单页栏目贴图),B2 初步标记) */
|
||||
@PostMapping("/upload")
|
||||
public AttachmentVo upload(BoaUser authUserWithDept,
|
||||
@RequestParam("siteId") Integer siteId,
|
||||
|
||||
@@ -133,6 +133,8 @@ public class PortalController {
|
||||
model.addAttribute("navCategories", site.getCategories());
|
||||
model.addAttribute("currentCategoryId", categoryId);
|
||||
model.addAttribute("category", category);
|
||||
// 栏目导航三件套(docs/11):面包屑全路径 + 顶导航祖先高亮 + 左侧兄弟侧栏
|
||||
pageRenderer.attachCategoryNavigation(site, categoryId, model);
|
||||
model.addAttribute("pageTitle", seoTitle(category, site));
|
||||
model.addAttribute("seoDescription", category.getSeoDescription());
|
||||
model.addAttribute("canonical", url.category(categoryId));
|
||||
@@ -198,6 +200,8 @@ public class PortalController {
|
||||
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
|
||||
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
|
||||
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
|
||||
// 栏目导航三件套(docs/11):详情页同栏目页规则;栏目已删时 path 空,模板按缺省形态渲染
|
||||
pageRenderer.attachCategoryNavigation(site, detail.getCategoryId(), model);
|
||||
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
|
||||
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
|
||||
model.addAttribute("article", detail.withContent(
|
||||
@@ -356,6 +360,7 @@ public class PortalController {
|
||||
item.setTitle(a.getTitle());
|
||||
item.setSummary(a.getSummary());
|
||||
item.setCoverImage(a.getCoverImage());
|
||||
item.setSource(a.getSource());
|
||||
item.setPublishTime(a.getPublishTime());
|
||||
item.setTopmost(a.getTopmost());
|
||||
return item;
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.yunhai.cms.enums.LinkTypeEnum;
|
||||
import com.yunhai.cms.model.portal.PortalBanner;
|
||||
import com.yunhai.cms.model.portal.PortalCategory;
|
||||
import com.yunhai.cms.model.portal.PortalListData;
|
||||
import com.yunhai.cms.model.portal.PortalSideNav;
|
||||
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
||||
import com.yunhai.cms.repository.CmsArticleRepository;
|
||||
import com.yunhai.cms.utils.PortalUrlHelper;
|
||||
@@ -28,8 +29,9 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class PortalPageRenderer {
|
||||
|
||||
/** 首页每个一级栏目取 N 条 */
|
||||
/** 首页每个一级栏目取 N 条(docs/12:双栏新闻卡形态需 16 条铺满 8 行 × 2 列) */
|
||||
private static final int HOME_BLOCK_SIZE = 8;
|
||||
private static final int HOME_BLOCK_GRID_SIZE = 16;
|
||||
/** 首页置顶区取 N 条 */
|
||||
private static final int HOME_TOP_SIZE = 6;
|
||||
|
||||
@@ -46,6 +48,7 @@ public class PortalPageRenderer {
|
||||
model.addAttribute("navCategories", site.getCategories());
|
||||
List<Map<String, Object>> banners = activeBanners(site, "home", url);
|
||||
model.addAttribute("banners", banners);
|
||||
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
|
||||
model.addAttribute("topArticles", topArticles(site.getId()));
|
||||
model.addAttribute("focusArticles", focusArticles(site.getId()));
|
||||
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
|
||||
@@ -63,6 +66,71 @@ public class PortalPageRenderer {
|
||||
|
||||
// ===== 内部装配(自 PortalController 原样迁移)=====
|
||||
|
||||
/**
|
||||
* 栏目导航装配(docs/11):栏目页/详情页共用,三条模型属性——
|
||||
* categoryPath(顶级→当前路径,面包屑全路径)、navTopId(顶导航祖先高亮)、
|
||||
* sideNav(模块有二级子栏目时的左侧兄弟导航,无则不挂,模板 th:if 兜底)。
|
||||
* 全部基于站点快照树推导,零新增查询;搜索/首页等无栏目上下文的路由不调用。
|
||||
*/
|
||||
public void attachCategoryNavigation(PortalSiteSnapshot site, Integer currentCategoryId, Model model) {
|
||||
if (currentCategoryId == null) {
|
||||
return;
|
||||
}
|
||||
List<PortalCategory> path = pathFromRoot(site.getCategories(), currentCategoryId);
|
||||
model.addAttribute("categoryPath", path);
|
||||
if (path.isEmpty()) {
|
||||
return; // 栏目已删等兜底(详情页可能挂已删栏目文章),模板按缺省形态渲染
|
||||
}
|
||||
PortalCategory top = path.get(0);
|
||||
model.addAttribute("navTopId", top.getId());
|
||||
if (!top.getChildren().isEmpty()) {
|
||||
PortalSideNav nav = new PortalSideNav();
|
||||
nav.setTop(top);
|
||||
nav.setItems(top.getChildren());
|
||||
nav.setActiveId(resolveActiveId(path, currentCategoryId));
|
||||
model.addAttribute("sideNav", nav);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏高亮位:当前为二级=当前栏目;一级直点=null(侧栏头即模块名,无高亮项);
|
||||
* 三级及更深=所在二级分支(决策点②:固定列二级层,高亮当前所属分支)。
|
||||
*/
|
||||
private Integer resolveActiveId(List<PortalCategory> path, Integer currentCategoryId) {
|
||||
if (path.size() == 2) {
|
||||
return currentCategoryId;
|
||||
}
|
||||
return path.size() > 2 ? path.get(1).getId() : null;
|
||||
}
|
||||
|
||||
/** 自栏目树求 根→目标 路径(含两端);目标不存在返回空表 */
|
||||
private List<PortalCategory> pathFromRoot(List<PortalCategory> roots, Integer categoryId) {
|
||||
for (PortalCategory root : roots) {
|
||||
List<PortalCategory> hit = pathFromRootDeep(root, categoryId);
|
||||
if (hit != null) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private List<PortalCategory> pathFromRootDeep(PortalCategory node, Integer categoryId) {
|
||||
if (node.getId().equals(categoryId)) {
|
||||
List<PortalCategory> path = new ArrayList<>();
|
||||
path.add(node);
|
||||
return path;
|
||||
}
|
||||
for (PortalCategory child : node.getChildren()) {
|
||||
List<PortalCategory> sub = pathFromRootDeep(child, categoryId);
|
||||
if (sub != null) {
|
||||
sub.add(0, node);
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
|
||||
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
|
||||
PortalUrlHelper url) {
|
||||
@@ -137,6 +205,7 @@ public class PortalPageRenderer {
|
||||
/**
|
||||
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
|
||||
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
|
||||
* docs/12:区块带出栏目 homeStyle,双栏新闻卡(news_grid)取 16 条铺满双栏,其余取 8 条。
|
||||
*/
|
||||
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
|
||||
PortalUrlHelper url) {
|
||||
@@ -145,12 +214,14 @@ public class PortalPageRenderer {
|
||||
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
|
||||
continue;
|
||||
}
|
||||
int blockSize = "news_grid".equals(category.getHomeStyle())
|
||||
? HOME_BLOCK_GRID_SIZE : HOME_BLOCK_SIZE;
|
||||
List<Integer> categoryIds = new ArrayList<>();
|
||||
collectListDescendantIds(category, categoryIds);
|
||||
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
|
||||
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
|
||||
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
||||
PageRequest.of(0, HOME_BLOCK_SIZE,
|
||||
PageRequest.of(0, blockSize,
|
||||
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
|
||||
.getContent();
|
||||
List<PortalListData.Item> items = new ArrayList<>();
|
||||
@@ -159,6 +230,7 @@ public class PortalPageRenderer {
|
||||
}
|
||||
Map<String, Object> block = new HashMap<>();
|
||||
block.put("category", category);
|
||||
block.put("homeStyle", category.getHomeStyle());
|
||||
block.put("items", items);
|
||||
block.put("moreUrl", url.category(category.getId()));
|
||||
blocks.add(block);
|
||||
@@ -182,6 +254,7 @@ public class PortalPageRenderer {
|
||||
item.setTitle(a.getTitle());
|
||||
item.setSummary(a.getSummary());
|
||||
item.setCoverImage(a.getCoverImage());
|
||||
item.setSource(a.getSource());
|
||||
item.setPublishTime(a.getPublishTime());
|
||||
item.setTopmost(a.getTopmost());
|
||||
return item;
|
||||
|
||||
@@ -42,6 +42,13 @@ public class CmsCategory extends BaseEntity {
|
||||
@Column(name = "template", length = 64)
|
||||
private String template;
|
||||
|
||||
/**
|
||||
* 首页展示形态(docs/11,空=主题默认卡):news_grid=双栏新闻卡、media_focus=来源标签列表。
|
||||
* 仅一级列表型栏目在首页生效;旧主题不消费该字段(仅条数外溢 16)。
|
||||
*/
|
||||
@Column(name = "home_style", length = 32)
|
||||
private String homeStyle;
|
||||
|
||||
/**
|
||||
* 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。
|
||||
* 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。
|
||||
|
||||
@@ -38,6 +38,10 @@ public class CategorySaveRequest {
|
||||
@Size(max = 64)
|
||||
private String template;
|
||||
|
||||
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus,服务端白名单校验) */
|
||||
@Size(max = 32)
|
||||
private String homeStyle;
|
||||
|
||||
/** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */
|
||||
private String content;
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ public class PortalCategory {
|
||||
|
||||
private String template;
|
||||
|
||||
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
|
||||
private String homeStyle;
|
||||
|
||||
private String seoTitle;
|
||||
|
||||
private String seoDescription;
|
||||
@@ -42,6 +45,7 @@ public class PortalCategory {
|
||||
node.setType(c.getType());
|
||||
node.setLinkUrl(c.getLinkUrl());
|
||||
node.setTemplate(c.getTemplate());
|
||||
node.setHomeStyle(c.getHomeStyle());
|
||||
node.setSeoTitle(c.getSeoTitle());
|
||||
node.setSeoDescription(c.getSeoDescription());
|
||||
node.setContent(c.getContent());
|
||||
@@ -60,6 +64,7 @@ public class PortalCategory {
|
||||
copy.type = type;
|
||||
copy.linkUrl = linkUrl;
|
||||
copy.template = template;
|
||||
copy.homeStyle = homeStyle;
|
||||
copy.seoTitle = seoTitle;
|
||||
copy.seoDescription = seoDescription;
|
||||
copy.content = content;
|
||||
|
||||
@@ -37,6 +37,9 @@ public class PortalListData {
|
||||
|
||||
private String coverImage;
|
||||
|
||||
/** 来源(转载出处,docs/11:媒体聚焦来源徽标用;可空) */
|
||||
private String source;
|
||||
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
private Integer topmost;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.cms.model.portal;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 门户左侧栏目导航(docs/11):模块存在二级子栏目时,栏目页/详情页左侧
|
||||
* 展示"模块名 + 全部二级子栏目",当前位高亮。纯展示模型,由站点快照树推导。
|
||||
*/
|
||||
@Data
|
||||
public class PortalSideNav {
|
||||
|
||||
/** 顶级模块(侧栏头) */
|
||||
private PortalCategory top;
|
||||
|
||||
/** 全部二级子栏目(快照序,列表/单页/外链型均列出) */
|
||||
private List<PortalCategory> items = new ArrayList<>();
|
||||
|
||||
/** 当前高亮栏目 id:当前为二级时=当前栏目;三级及更深时=所在二级分支;一级直点=null */
|
||||
private Integer activeId;
|
||||
}
|
||||
@@ -63,6 +63,12 @@ public class PortalSiteSnapshot {
|
||||
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
|
||||
private String customCss = "";
|
||||
|
||||
/**
|
||||
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
|
||||
* 规避模板内拆分的表达式平台差异)。
|
||||
*/
|
||||
private List<String> footerAddressLines = new ArrayList<>();
|
||||
|
||||
public static PortalSiteSnapshot of(CmsSite site) {
|
||||
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
|
||||
snapshot.setId(site.getId());
|
||||
@@ -78,9 +84,25 @@ public class PortalSiteSnapshot {
|
||||
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
||||
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
||||
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
|
||||
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
|
||||
private static List<String> splitAddressLines(String raw) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
return lines;
|
||||
}
|
||||
for (String line : raw.split("\r?\n")) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
lines.add(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss
|
||||
* 的操作由预览方在拷贝上进行,不影响缓存中的原快照。
|
||||
@@ -104,6 +126,7 @@ public class PortalSiteSnapshot {
|
||||
copy.setThemeParams(themeParams);
|
||||
copy.setThemeVarsCss(themeVarsCss);
|
||||
copy.setCustomCss(customCss);
|
||||
copy.setFooterAddressLines(footerAddressLines);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ public class CategoryTreeNode {
|
||||
|
||||
private String template;
|
||||
|
||||
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
|
||||
private String homeStyle;
|
||||
|
||||
/** 单页栏目内容(type=2 时编辑/渲染用) */
|
||||
private String content;
|
||||
|
||||
@@ -50,6 +53,7 @@ public class CategoryTreeNode {
|
||||
node.setType(c.getType());
|
||||
node.setLinkUrl(c.getLinkUrl());
|
||||
node.setTemplate(c.getTemplate());
|
||||
node.setHomeStyle(c.getHomeStyle());
|
||||
node.setContent(c.getContent());
|
||||
node.setSortNo(c.getSortNo());
|
||||
node.setSeoTitle(c.getSeoTitle());
|
||||
|
||||
@@ -56,8 +56,8 @@ public class AttachmentService {
|
||||
private static final List<String> IMAGE_EXTS = Arrays.asList(
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
|
||||
|
||||
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 */
|
||||
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site");
|
||||
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 / category=单页栏目贴图 */
|
||||
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site", "category");
|
||||
|
||||
private final CmsAttachmentRepository attachmentRepository;
|
||||
private final SiteService siteService;
|
||||
@@ -69,12 +69,12 @@ public class AttachmentService {
|
||||
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
||||
private String fileWebPath;
|
||||
|
||||
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site,B2 初步标记) */
|
||||
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category,B2 初步标记) */
|
||||
@Transactional
|
||||
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
|
||||
CmsSite site = siteService.requireManageable(user, siteId);
|
||||
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site)");
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site/category)");
|
||||
}
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
||||
|
||||
@@ -33,6 +33,10 @@ import java.util.stream.Collectors;
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
|
||||
/** 首页展示形态白名单(docs/11;空=主题默认卡) */
|
||||
private static final java.util.Set<String> HOME_STYLE_VALUES =
|
||||
new java.util.HashSet<>(java.util.Arrays.asList("news_grid", "media_focus"));
|
||||
|
||||
private final CmsCategoryRepository categoryRepository;
|
||||
private final CmsArticleRepository articleRepository;
|
||||
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
|
||||
@@ -70,6 +74,10 @@ public class CategoryService {
|
||||
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
|
||||
}
|
||||
if (StringUtils.hasText(req.getHomeStyle()) && !HOME_STYLE_VALUES.contains(req.getHomeStyle())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"首页展示形态非法:" + req.getHomeStyle() + "(仅支持 news_grid / media_focus)");
|
||||
}
|
||||
CmsCategory category;
|
||||
if (req.getId() == null) {
|
||||
category = new CmsCategory();
|
||||
@@ -87,6 +95,7 @@ public class CategoryService {
|
||||
category.setType(type.getCode());
|
||||
category.setLinkUrl(req.getLinkUrl());
|
||||
category.setTemplate(req.getTemplate());
|
||||
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : null);
|
||||
category.setContent(HtmlSanitizer.clean(req.getContent()));
|
||||
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||
category.setSeoTitle(req.getSeoTitle());
|
||||
|
||||
@@ -194,6 +194,7 @@ public class PortalQueryService {
|
||||
item.setTitle(a.getTitle());
|
||||
item.setSummary(a.getSummary());
|
||||
item.setCoverImage(a.getCoverImage());
|
||||
item.setSource(a.getSource());
|
||||
item.setPublishTime(a.getPublishTime());
|
||||
item.setTopmost(a.getTopmost());
|
||||
data.getItems().add(item);
|
||||
@@ -246,6 +247,7 @@ public class PortalQueryService {
|
||||
item.setTitle(a.getTitle());
|
||||
item.setSummary(a.getSummary());
|
||||
item.setCoverImage(a.getCoverImage());
|
||||
item.setSource(a.getSource());
|
||||
item.setPublishTime(a.getPublishTime());
|
||||
item.setTopmost(a.getTopmost());
|
||||
return item;
|
||||
|
||||
@@ -287,7 +287,8 @@ public class SiteService {
|
||||
/**
|
||||
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
|
||||
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
|
||||
* ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。
|
||||
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
|
||||
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
|
||||
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
|
||||
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
|
||||
*/
|
||||
@@ -337,6 +338,10 @@ public class SiteService {
|
||||
}
|
||||
break;
|
||||
case "public_base_url":
|
||||
case "social_wechat":
|
||||
case "social_weibo":
|
||||
case "social_douyin":
|
||||
case "social_bilibili":
|
||||
if (!URL_PATTERN.matcher(text).matches()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"对外基地址必须是完整的 http(s):// 地址:" + text);
|
||||
@@ -344,6 +349,9 @@ public class SiteService {
|
||||
break;
|
||||
case "logo":
|
||||
case "favicon":
|
||||
case "qr_wechat":
|
||||
case "qr_weibo":
|
||||
case "news_bg_image":
|
||||
if (!ASSET_PATTERN.matcher(text).matches()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
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();
|
||||
})();
|
||||
})();
|
||||
|
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}">更多 ></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>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.yunhai.cms.portal;
|
||||
|
||||
import com.yunhai.cms.entity.CmsArticle;
|
||||
import com.yunhai.cms.entity.CmsCategory;
|
||||
import com.yunhai.cms.entity.CmsSite;
|
||||
import com.yunhai.cms.enums.ArticleStatusEnum;
|
||||
import com.yunhai.cms.enums.CategoryTypeEnum;
|
||||
import com.yunhai.cms.support.CmsIntegrationTestBase;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 门户首页新板块(docs/12)SSR 契约:campus-glow 皮肤下——
|
||||
* 双栏新闻卡(homeStyle=news_grid,取 16 条)、媒体聚焦来源徽标(homeStyle=media_focus + article.source)、
|
||||
* 线上展厅画廊(banner position=gallery)、个性化页脚(footer_addresses 派生行/二维码/社媒/返回顶部)。
|
||||
* 同时守护:旧主题(default)不消费 homeStyle 时不渲染新板块 markup。
|
||||
*/
|
||||
@DisplayName("Portal HomeBlocks - 首页双栏新闻卡/媒体聚焦/画廊/页脚增强(docs/12)")
|
||||
class PortalHomeBlocksIT extends CmsIntegrationTestBase {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private Integer newsGridCatId;
|
||||
private Integer mediaCatId;
|
||||
private Integer normalCatId;
|
||||
|
||||
@BeforeEach
|
||||
void seedHome() {
|
||||
purgeTables();
|
||||
CmsSite site = persistSite("glow", 1, "炫彩测试门户");
|
||||
site.setTheme("campus-glow");
|
||||
site.setConfigJson("{\"footer_addresses\":\"潭山校区:南湖路237号 邮编:464000\\n淮河校区:大学路1号 邮编:464300\","
|
||||
+ "\"qr_wechat\":\"/cms/upload/qr-wechat.png\","
|
||||
+ "\"social_weibo\":\"https://weibo.com/xxx\","
|
||||
+ "\"news_bg_image\":\"/cms/upload/campus.jpg\","
|
||||
+ "\"gallery_title\":\"线上展厅\"}");
|
||||
siteRepository.saveAndFlush(site);
|
||||
Integer siteId = site.getId();
|
||||
|
||||
// 双栏新闻卡栏目(>8 条验证 16 条装配)
|
||||
newsGridCatId = persistCategory(siteId, 0, "校园时讯", CategoryTypeEnum.LIST).getId();
|
||||
setHomeStyle(newsGridCatId, "news_grid");
|
||||
// 媒体聚焦栏目(带来源)
|
||||
mediaCatId = persistCategory(siteId, 0, "媒体聚焦", CategoryTypeEnum.LIST).getId();
|
||||
setHomeStyle(mediaCatId, "media_focus");
|
||||
// 默认形态栏目
|
||||
normalCatId = persistCategory(siteId, 0, "通知公告", CategoryTypeEnum.LIST).getId();
|
||||
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
persistArticle(siteId, newsGridCatId, "校园时讯文章" + i,
|
||||
ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60L * i));
|
||||
}
|
||||
persistArticle(siteId, mediaCatId, "媒体报道一", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(30)).getId();
|
||||
CmsArticle media2 = persistArticle(siteId, mediaCatId, "媒体报道二",
|
||||
ArticleStatusEnum.PUBLISHED, nowMinusSeconds(20));
|
||||
media2.setSource("大象新闻");
|
||||
articleRepository.saveAndFlush(media2);
|
||||
persistArticle(siteId, normalCatId, "通知文章一", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(10));
|
||||
|
||||
// 轮播:home 位 + gallery 位(gallery 一张即可渲染画廊)
|
||||
persistBanner(siteId, "home", "首页轮播图", null, null);
|
||||
persistBanner(siteId, "gallery", "茶韵鱼缘作品", null, null);
|
||||
|
||||
clearCaches();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clean() {
|
||||
purgeTables();
|
||||
}
|
||||
|
||||
private void setHomeStyle(Integer categoryId, String homeStyle) {
|
||||
categoryRepository.findById(categoryId).ifPresent(c -> {
|
||||
c.setHomeStyle(homeStyle);
|
||||
categoryRepository.saveAndFlush(c);
|
||||
});
|
||||
}
|
||||
|
||||
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
|
||||
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("campus-glow 首页:三种形态区块 + 画廊 + 页脚增强全部渲染")
|
||||
void glowHome_rendersAllNewBlocks() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/glow/")).andExpect(status().isOk()).andReturn());
|
||||
|
||||
// 双栏新闻卡:形态 markup + 背景图(url.upload 补基准)+ 10 条全部渲染(证明 16 条装配生效)
|
||||
assertThat(html).contains("cg-news-grid").contains("cg-news-date");
|
||||
assertThat(html).contains("campus.jpg");
|
||||
assertThat(html).contains("校园时讯文章10");
|
||||
assertThat(count(html, "cg-news-title")).as("双栏新闻卡应渲染全部 10 条(>8 证明 16 条装配)").isEqualTo(10);
|
||||
|
||||
// 媒体聚焦:来源徽标(有 source 才渲染徽标)
|
||||
assertThat(html).contains("cg-source-badge").contains("大象新闻");
|
||||
|
||||
// 默认形态区块保持常规卡
|
||||
assertThat(html).contains("cg-block-card").contains("通知文章一");
|
||||
|
||||
// 线上展厅画廊:文区头(config 覆盖 + 缺省英文副题)+ gallery 位图
|
||||
assertThat(html).contains("cg-gallery-stage").contains("cg-gallery-item");
|
||||
assertThat(html).contains("线上展厅").contains("Online Exhibition Hall");
|
||||
assertThat(html).contains("茶韵鱼缘作品");
|
||||
|
||||
// 要闻速递横条 + 页脚增强:多行地址、二维码、社媒图标、返回顶部
|
||||
assertThat(html).contains("cg-ticker");
|
||||
assertThat(html).contains("潭山校区:南湖路237号 邮编:464000").contains("淮河校区:大学路1号 邮编:464300");
|
||||
assertThat(html).contains("qr-wechat.png").contains("cg-backtop");
|
||||
assertThat(html).contains("weibo.com/xxx");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("campus-glow 列表页:行内展示来源")
|
||||
void glowList_showsSource() throws Exception {
|
||||
Integer mediaArticleId = articleRepository.findBySiteIdAndStatus(
|
||||
siteIdForGlow(), com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
||||
org.springframework.data.domain.PageRequest.of(0, 50)).stream()
|
||||
.filter(a -> "媒体报道二".equals(a.getTitle())).findFirst()
|
||||
.orElseThrow(IllegalStateException::new).getId();
|
||||
String html = body(mockMvc.perform(get("/portal/glow/c/{id}", mediaCatId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("来源:大象新闻");
|
||||
assertThat(html).contains("/a/" + mediaArticleId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default 旧主题:同数据下不渲染 campus-glow 新板块 markup(换皮不换骨不受影响)")
|
||||
void defaultTheme_unaffectedByHomeStyle() throws Exception {
|
||||
CmsSite site = persistSite("plain", 2, "普通门户");
|
||||
Integer catId = persistCategory(site.getId(), 0, "普通栏目", CategoryTypeEnum.LIST).getId();
|
||||
setHomeStyle(catId, "media_focus");
|
||||
persistArticle(site.getId(), catId, "普通文章", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60));
|
||||
persistBanner(site.getId(), "gallery", "画廊图", null, null);
|
||||
clearCaches();
|
||||
|
||||
String html = body(mockMvc.perform(get("/portal/plain/")).andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("普通文章");
|
||||
assertThat(html).doesNotContain("cg-news-grid").doesNotContain("cg-source-badge")
|
||||
.doesNotContain("cg-gallery-stage");
|
||||
}
|
||||
|
||||
private Integer siteIdForGlow() {
|
||||
return siteRepository.findByCode("glow").orElseThrow(IllegalStateException::new).getId();
|
||||
}
|
||||
|
||||
private static int count(String html, String marker) {
|
||||
int idx = 0, hits = 0;
|
||||
while ((idx = html.indexOf(marker, idx)) >= 0) {
|
||||
hits++;
|
||||
idx += marker.length();
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.yunhai.cms.portal;
|
||||
|
||||
import com.yunhai.cms.enums.ArticleStatusEnum;
|
||||
import com.yunhai.cms.enums.CategoryTypeEnum;
|
||||
import com.yunhai.cms.support.CmsIntegrationTestBase;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 门户左侧栏目导航(docs/11)规则矩阵:
|
||||
* 有二级节点的模块,其下任意栏目页/详情页左侧展示"模块名 + 全部二级兄弟";
|
||||
* 无二级节点的模块维持单栏;搜索页无侧栏;顶导航祖先高亮;面包屑全路径。
|
||||
*/
|
||||
@DisplayName("Portal SideNav - 左侧栏目导航规则矩阵(docs/11)")
|
||||
class PortalSideNavIT extends CmsIntegrationTestBase {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private Integer moduleId;
|
||||
private Integer listChildId;
|
||||
private Integer listChild2Id;
|
||||
private Integer singleChildId;
|
||||
private Integer linkChildId;
|
||||
private Integer grandChildId;
|
||||
private Integer standaloneCatId;
|
||||
private Integer standaloneSingleId;
|
||||
private Integer articleId;
|
||||
|
||||
@BeforeEach
|
||||
void seedTree() {
|
||||
purgeTables();
|
||||
Integer siteId = persistSite("snav", 1, "侧栏测试门户").getId();
|
||||
|
||||
// 模块"团学工作":二级含 列表×2 + 单页 + 外链;"团学动态"下再挂三级
|
||||
moduleId = persistCategory(siteId, 0, "团学工作", CategoryTypeEnum.LIST).getId();
|
||||
listChildId = persistCategory(siteId, moduleId, "学工队伍", CategoryTypeEnum.LIST).getId();
|
||||
listChild2Id = persistCategory(siteId, moduleId, "团学动态", CategoryTypeEnum.LIST).getId();
|
||||
singleChildId = persistCategory(siteId, moduleId, "心理健康", CategoryTypeEnum.SINGLE).getId();
|
||||
linkChildId = persistCategory(siteId, moduleId, "校外链接", CategoryTypeEnum.LINK).getId();
|
||||
categoryRepository.findById(singleChildId).ifPresent(c -> {
|
||||
c.setContent("<p>心理健康服务页。</p>");
|
||||
categoryRepository.saveAndFlush(c);
|
||||
});
|
||||
categoryRepository.findById(linkChildId).ifPresent(c -> {
|
||||
c.setLinkUrl("https://www.example.com");
|
||||
categoryRepository.saveAndFlush(c);
|
||||
});
|
||||
grandChildId = persistCategory(siteId, listChild2Id, "三级子栏目", CategoryTypeEnum.LIST).getId();
|
||||
|
||||
// 无二级节点的独立模块:列表型 + 单页型
|
||||
standaloneCatId = persistCategory(siteId, 0, "独立栏目", CategoryTypeEnum.LIST).getId();
|
||||
standaloneSingleId = persistCategory(siteId, 0, "独立单页", CategoryTypeEnum.SINGLE).getId();
|
||||
categoryRepository.findById(standaloneSingleId).ifPresent(c -> {
|
||||
c.setContent("<p>独立单页正文。</p>");
|
||||
categoryRepository.saveAndFlush(c);
|
||||
});
|
||||
|
||||
articleId = persistArticle(siteId, listChildId, "侧栏上下文文章",
|
||||
ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60)).getId();
|
||||
|
||||
// 站点快照含栏目树,种数后清缓存重载
|
||||
clearCaches();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clean() {
|
||||
purgeTables();
|
||||
}
|
||||
|
||||
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
|
||||
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("二级列表页:侧栏出齐模块名+全部兄弟,当前项高亮,面包屑全路径")
|
||||
void secondLevelList_showsSiblingsWithActive() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/c/{id}", listChildId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("side-nav-head");
|
||||
assertThat(html).contains("团学工作");
|
||||
assertThat(html).contains("学工队伍").contains("团学动态")
|
||||
.contains("心理健康").contains("校外链接");
|
||||
// 当前项高亮;外链兄弟 target=_blank
|
||||
assertThat(html).contains("side-nav-item active");
|
||||
assertThat(html).contains("target=\"_blank\"");
|
||||
// 面包屑全路径:首页 / 团学工作 / 团学动态(当前)
|
||||
assertThat(html).contains("crumb-parent");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("一级直点:侧栏仍出(项=全部二级),无高亮项")
|
||||
void topLevelDirect_showsSideNavWithoutActive() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/c/{id}", moduleId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("side-nav-head");
|
||||
assertThat(html).contains("学工队伍").contains("团学动态")
|
||||
.contains("心理健康").contains("校外链接");
|
||||
assertThat(html).doesNotContain("side-nav-item active");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单页二级:同样带侧栏并高亮当前")
|
||||
void secondLevelSingle_showsSideNav() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/c/{id}", singleChildId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("心理健康服务页");
|
||||
assertThat(html).contains("side-nav-head").contains("side-nav-item active");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("文章详情:带侧栏并高亮所属二级,面包屑含正文尾段")
|
||||
void articleDetail_showsSideNavWithOwnerActive() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/a/{id}", articleId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("侧栏上下文文章");
|
||||
assertThat(html).contains("side-nav-head").contains("side-nav-item active");
|
||||
int i = html.indexOf("side-nav-item active");
|
||||
assertThat(html.substring(i, i + 300)).as("高亮项应为所属二级'学工队伍'").contains("学工队伍");
|
||||
assertThat(html).contains("正文");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("三级栏目:侧栏固定列二级层,高亮所在二级分支")
|
||||
void thirdLevel_highlightsContainingBranch() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/c/{id}", grandChildId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).contains("side-nav-head");
|
||||
int i = html.indexOf("side-nav-item active");
|
||||
assertThat(i).as("三级栏目应高亮所在二级分支").isGreaterThan(-1);
|
||||
assertThat(html.substring(i, i + 300)).contains("团学动态");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无二级节点模块:列表/单页均不渲染侧栏")
|
||||
void standaloneModule_noSideNav() throws Exception {
|
||||
String listHtml = body(mockMvc.perform(get("/portal/snav/c/{id}", standaloneCatId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(listHtml).doesNotContain("side-nav");
|
||||
String singleHtml = body(mockMvc.perform(get("/portal/snav/c/{id}", standaloneSingleId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(singleHtml).doesNotContain("side-nav");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("搜索页:无栏目上下文,不渲染侧栏")
|
||||
void searchPage_noSideNav() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/search").param("q", "侧栏"))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
assertThat(html).doesNotContain("side-nav");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("顶导航祖先高亮:停在二级页面时一级模块 li 保持 active")
|
||||
void topNav_ancestorHighlight() throws Exception {
|
||||
String html = body(mockMvc.perform(get("/portal/snav/c/{id}", listChildId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
// default 主题导航 li:active+has-sub 同落模块"团学工作";首页 li 不应 active
|
||||
assertThat(html).containsPattern("<li class=\"active has-sub\">[\\s\\S]*?团学工作");
|
||||
String html2 = body(mockMvc.perform(get("/portal/snav/c/{id}", standaloneCatId))
|
||||
.andExpect(status().isOk()).andReturn());
|
||||
// 无二级模块:自身精确命中,active 且无 has-sub
|
||||
assertThat(html2).containsPattern("<li class=\"active\">[\\s\\S]*?独立栏目");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,40 @@ class CategoryServiceIT extends CmsIntegrationTestBase {
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===== docs/12 首页展示形态 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("docs/12:homeStyle 合法值落库且 tree 回显;空串归一为 null(默认卡)")
|
||||
void save_homeStyleAcceptedAndEchoed() {
|
||||
CategorySaveRequest grid = req("校园时讯", 0, 1);
|
||||
grid.setHomeStyle("news_grid");
|
||||
CategoryTreeNode gridNode = categoryService.save(TestUsers.superAdmin(), grid);
|
||||
assertThat(gridNode.getHomeStyle()).isEqualTo("news_grid");
|
||||
|
||||
CategorySaveRequest media = req("媒体聚焦", 0, 1);
|
||||
media.setHomeStyle("media_focus");
|
||||
assertThat(categoryService.save(TestUsers.superAdmin(), media).getHomeStyle()).isEqualTo("media_focus");
|
||||
|
||||
CategorySaveRequest blank = req("普通栏目", 0, 1);
|
||||
blank.setHomeStyle(" ");
|
||||
assertThat(categoryService.save(TestUsers.superAdmin(), blank).getHomeStyle()).isNull();
|
||||
|
||||
// tree 回显(管理端编辑回填依赖)
|
||||
List<CategoryTreeNode> tree = categoryService.tree(TestUsers.superAdmin(), siteId);
|
||||
assertThat(tree.stream().filter(n -> "校园时讯".equals(n.getName()))
|
||||
.findFirst().orElseThrow(IllegalStateException::new).getHomeStyle()).isEqualTo("news_grid");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("docs/12:homeStyle 非法值被白名单拒绝")
|
||||
void save_homeStyleInvalidRejected() {
|
||||
CategorySaveRequest bad = req("坏形态栏目", 0, 1);
|
||||
bad.setHomeStyle("super_flashy");
|
||||
assertThatThrownBy(() -> categoryService.save(TestUsers.superAdmin(), bad))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("首页展示形态");
|
||||
}
|
||||
|
||||
// ===== 正常流程 =====
|
||||
|
||||
@Test
|
||||
|
||||
@@ -186,6 +186,40 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
||||
assertThat(siteService.save(TestUsers.superAdmin(), compactEmpty)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("docs/12 config 新键:qr_*/news_bg_image 走 asset 校验、social_* 走 url 校验、footer_addresses 文本放行")
|
||||
void save_docs12ConfigKeysValidation() {
|
||||
SiteSaveRequest base = new SiteSaveRequest();
|
||||
base.setCode("cfg12");
|
||||
base.setSchId(1);
|
||||
base.setName("页脚增强配置站");
|
||||
|
||||
// 二维码/背景图:非资产路径拒绝
|
||||
base.setConfigJson("{\"qr_wechat\":\"javascript:alert(1)\"}");
|
||||
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), base))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("上传路径");
|
||||
base.setConfigJson("{\"news_bg_image\":\"not-asset\"}");
|
||||
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), base))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("上传路径");
|
||||
|
||||
// 社媒链接:非完整 URL 拒绝
|
||||
base.setConfigJson("{\"social_weibo\":\"weibo.com/xxx\"}");
|
||||
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), base))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("http");
|
||||
|
||||
// 合法组合通过(多行地址 + 二维码 + 社媒 + 文区头文案)
|
||||
base.setConfigJson("{\"footer_addresses\":\"潭山校区:南湖路237号 邮编:464000\\n淮河校区:大学路1号 邮编:464300\","
|
||||
+ "\"qr_wechat\":\"/cms/upload/qr-wechat.png\",\"qr_weibo\":\"/cms/upload/qr-weibo.png\","
|
||||
+ "\"social_wechat\":\"https://mp.weixin.qq.com/xxx\",\"social_bilibili\":\"https://space.bilibili.com/123\","
|
||||
+ "\"news_bg_image\":\"/cms/upload/campus.jpg\",\"gallery_title\":\"线上展厅\",\"gallery_subtitle\":\"Online Exhibition Hall\"}");
|
||||
SiteVo vo = siteService.save(TestUsers.superAdmin(), base);
|
||||
assertThat(siteService.configValue(siteService.getByCode(vo.getCode()), "footer_addresses"))
|
||||
.contains("邮编:464300");
|
||||
}
|
||||
|
||||
// ===== 主题白名单与 themeParams 合并(方案 §5.1/§5.3,T9-T12)=====
|
||||
|
||||
@Test
|
||||
|
||||