feat(portal): docs/16 首页布局后端- home_layout零DDL消费与主题fragment化-PortalSiteSnapshot.homeLayout+normalizeHomeLayout脏数据容错;PortalPageRenderer.buildLayoutBlocks按区块序列渲染(article_list多栏目聚合+homeStyle别名+category合成+background回落news_bg_image/ticker top|category双源/gallery标题副题缺省旧键/links分组过滤/visible跳过/未知type跳过/size 1~20夹取);布局模式清空topArticles/focusArticles/categoryBlocks/galleryBanners回落段;SiteService.validateConfigJson带siteId收紧home_layout(blocks<=30/type白名单/size 1~20/文本<=100/categoryIds归属校验:新建拒跨站拒不存在拒);themes/common/fragments/home-blocks.html plb-*兜底骨架(不注册皮肤包,Thymeleaf classpath可解析);campus-glow自有fragment(news_grid/media_focus/card/ticker/gallery)原home.html抽出;default+blue-white+classic-red+fresh-mint+mint-breeze+warm-kinder+scholar-blue+campus-glow八套home.html接入布局分支(th:case父元素+th:replace子元素嵌套,replace优先级100先于case执行);新增PortalHomeLayoutIT 5+SiteServiceHomeLayoutIT 3,全量330绿
build / build-push (push) Successful in 52s

This commit is contained in:
chenxing
2026-09-10 15:08:40 +08:00
parent 98d640faba
commit 39454e4efb
15 changed files with 1233 additions and 143 deletions
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用, * 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
@@ -37,7 +38,9 @@ public class PortalPageRenderer {
private final CmsArticleRepository articleRepository; private final CmsArticleRepository articleRepository;
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */ /** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
* 原样回落现状装配(D2 兼容,老站一个像素都不变)。 */
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) { public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
model.addAttribute("url", url); model.addAttribute("url", url);
model.addAttribute("site", site); model.addAttribute("site", site);
@@ -48,10 +51,20 @@ public class PortalPageRenderer {
model.addAttribute("navCategories", site.getCategories()); model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url); List<Map<String, Object>> banners = activeBanners(site, "home", url);
model.addAttribute("banners", banners); model.addAttribute("banners", banners);
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url)); List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
model.addAttribute("topArticles", topArticles(site.getId())); if (layoutBlocks != null) {
model.addAttribute("focusArticles", focusArticles(site.getId())); // 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url)); model.addAttribute("layoutBlocks", layoutBlocks);
model.addAttribute("galleryBanners", new ArrayList<>());
model.addAttribute("topArticles", new ArrayList<>());
model.addAttribute("focusArticles", new ArrayList<>());
model.addAttribute("categoryBlocks", new ArrayList<>());
} else {
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId(), HOME_TOP_SIZE));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
}
model.addAttribute("pageTitle", site.getName()); model.addAttribute("pageTitle", site.getName());
model.addAttribute("seoDescription", site.getConfig() == null ? null model.addAttribute("seoDescription", site.getConfig() == null ? null
: site.getConfig().get("seo_description")); : site.getConfig().get("seo_description"));
@@ -169,10 +182,10 @@ public class PortalPageRenderer {
} }
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */ /** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
private List<PortalListData.Item> topArticles(Integer siteId) { private List<PortalListData.Item> topArticles(Integer siteId, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus( List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(), siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_TOP_SIZE, PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime")))); Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>(); List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) { for (com.yunhai.cms.entity.CmsArticle a : articles) {
@@ -248,6 +261,250 @@ public class PortalPageRenderer {
} }
} }
// ===== 首页布局装配(docs/16 主线B §4.4)=====
/** 布局区块条数上限与缺省(与编辑器/服务端校验同约束) */
private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
* 每块输出与 categoryBlocks 同构的 Map(category.name/homeStyle 供主题 fragment 兼容消费),
* 另带 type/style/title/subtitle/background/source 布局属性;visible=false 的块保序跳过。
*/
private List<Map<String, Object>> buildLayoutBlocks(PortalSiteSnapshot site, Map<String, Object> homeLayout,
PortalUrlHelper url) {
if (homeLayout == null) {
return null;
}
Object raw = homeLayout.get("blocks");
if (!(raw instanceof List)) {
return null; // 容错:blocks 非数组视为无布局
}
Set<Integer> treeIds = new java.util.HashSet<>();
collectTreeIds(site.getCategories(), treeIds);
List<Map<String, Object>> result = new ArrayList<>();
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<String, Object> spec = (Map<String, Object>) o;
if (Boolean.FALSE.equals(spec.get("visible"))) {
continue; // 编辑器「隐藏」:保序下线
}
Map<String, Object> block = buildLayoutBlock(site, spec, treeIds, url);
if (block != null) {
result.add(block);
}
}
return result;
}
private void collectTreeIds(List<PortalCategory> categories, Set<Integer> ids) {
for (PortalCategory c : categories) {
ids.add(c.getId());
collectTreeIds(c.getChildren(), ids);
}
}
private Map<String, Object> buildLayoutBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
String type = spec.get("type") == null ? "" : String.valueOf(spec.get("type"));
switch (type) {
case "article_list":
return buildArticleListBlock(site, spec, treeIds, url);
case "ticker":
return buildTickerBlock(site, spec, treeIds);
case "gallery":
return buildGalleryBlock(site, spec, url);
case "links":
return buildLinksBlock(site, spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
}
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题) */
private Map<String, Object> baseBlock(Map<String, Object> spec, String type, String fallbackTitle) {
Map<String, Object> block = new HashMap<>();
block.put("type", type);
String title = spec.get("title") == null ? "" : String.valueOf(spec.get("title")).trim();
if (title.isEmpty()) {
title = fallbackTitle;
}
block.put("title", title);
Map<String, Object> category = new HashMap<>();
category.put("name", title);
block.put("category", category);
return block;
}
/**
* 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入),
* 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。
*/
private Map<String, Object> buildArticleListBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
List<Integer> categoryIds = new ArrayList<>();
PortalCategory first = null;
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node == null) {
continue;
}
collectListDescendantIds(node, categoryIds);
if (first == null) {
first = node;
}
}
int size = clampSize(spec.get("size"), LAYOUT_LIST_SIZE_DEFAULT);
List<PortalListData.Item> items = categoryIds.isEmpty()
? new ArrayList<>()
: articlesIn(site.getId(), categoryIds, size);
String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style"));
Map<String, Object> block = baseBlock(spec, "article_list",
first != null ? first.getName() : "栏目文章");
block.put("style", style);
block.put("homeStyle", style); // 主题 fragment 兼容:cg-newshot 等按 homeStyle 分支
block.put("items", items);
block.put("size", size);
block.put("moreUrl", first != null ? url.category(first.getId()) : url.home());
// 背景大图(§4.5):区块未设 background 时以旧键 news_bg_image 为默认值(存量配置无损生效)
boolean background = spec.get("background") != null
? Boolean.parseBoolean(String.valueOf(spec.get("background")))
: hasText(configValue(site, "news_bg_image"));
block.put("background", background);
return block;
}
/** 滚动速递:source=top 走全站置顶;source=category 走栏目 IN 查询(树外栏目跳过) */
private Map<String, Object> buildTickerBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source"));
int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT);
List<PortalListData.Item> items;
if ("category".equals(source)) {
List<Integer> categoryIds = new ArrayList<>();
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node != null) {
collectListDescendantIds(node, categoryIds);
}
}
items = categoryIds.isEmpty() ? new ArrayList<>() : articlesIn(site.getId(), categoryIds, size);
} else {
items = topArticles(site.getId(), size);
}
Map<String, Object> block = baseBlock(spec, "ticker", "要闻速递");
block.put("source", source);
block.put("items", items);
block.put("size", size);
return block;
}
/**
* 图片画廊:数据通道不变(轮播位 gallery,轮播管理维护)。
* 标题/副题(§4.5):区块未设时以旧键 gallery_title/gallery_subtitle 为默认值;
* size 未设 = 全部展示(历史站点画廊数量不定,不加隐式上限)。
*/
private Map<String, Object> buildGalleryBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
Map<String, Object> config = site.getConfig() == null ? new HashMap<>() : site.getConfig();
Map<String, Object> block = baseBlock(spec, "gallery", str(config.get("gallery_title"), "线上展厅"));
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (subtitle.isEmpty()) {
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
}
block.put("subtitle", subtitle);
block.put("items", activeBanners(site, "gallery", url));
return block;
}
/** 链接组:友情链接分组(group 未设 = 全部分组;首页此前未消费该数据,现有通道直出) */
private Map<String, Object> buildLinksBlock(PortalSiteSnapshot site, Map<String, Object> spec) {
String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim();
List<com.yunhai.cms.model.portal.PortalLinkGroup> groups = new ArrayList<>();
for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) {
if (group.isEmpty() || group.equals(g.getGroupName())) {
groups.add(g);
}
}
Map<String, Object> block = baseBlock(spec, "links", "友情链接");
block.put("group", group);
block.put("linkGroups", groups);
return block;
}
// ===== 布局装配工具 =====
private static String str(Object v, String fallback) {
return v == null ? fallback : String.valueOf(v);
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private static String configValue(PortalSiteSnapshot site, String key) {
Object v = site.getConfig() == null ? null : site.getConfig().get(key);
return v == null ? null : String.valueOf(v);
}
private static List<Integer> intList(Object v) {
List<Integer> ids = new ArrayList<>();
if (v instanceof List) {
for (Object o : (List<?>) v) {
if (o instanceof Number && ((Number) o).intValue() > 0) {
ids.add(((Number) o).intValue());
}
}
}
return ids;
}
private static int clampSize(Object v, int fallback) {
if (!(v instanceof Number)) {
return fallback;
}
int n = ((Number) v).intValue();
return Math.max(1, Math.min(LAYOUT_SIZE_MAX, n));
}
private List<PortalListData.Item> articlesIn(Integer siteId, List<Integer> categoryIds, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */
private PortalCategory findInTree(List<PortalCategory> categories, Integer id) {
for (PortalCategory c : categories) {
if (c.getId().equals(id)) {
return c;
}
PortalCategory hit = findInTree(c.getChildren(), id);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) { private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
PortalListData.Item item = new PortalListData.Item(); PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId()); item.setId(a.getId());
@@ -63,6 +63,13 @@ public class PortalSiteSnapshot {
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */ /** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
private String customCss = ""; private String customCss = "";
/**
* 首页布局(docs/16 主线B:config_json.home_layout,{version, blocks:[…]})。
* null = 未编排布局(渲染端回落「全部栏目自动渲染」现状,老站零迁移);
* 解析容错:键缺失/非法结构一律 null,绝不让脏布局打挂门户。
*/
private Map<String, Object> homeLayout;
/** /**
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存, * 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
* 规避模板内拆分的表达式平台差异)。 * 规避模板内拆分的表达式平台差异)。
@@ -84,10 +91,20 @@ public class PortalSiteSnapshot {
snapshot.setDomain(ExtJsonUtils.getString(config, "domain")); snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url")); snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css")); snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses"))); snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
return snapshot; return snapshot;
} }
/** home_layout 容错归一:对象且 blocks 为数组才生效,否则视为无布局(回落态) */
private static Map<String, Object> normalizeHomeLayout(Object raw) {
if (!(raw instanceof Map)) {
return null;
}
Object blocks = ((Map<?, ?>) raw).get("blocks");
return blocks instanceof List ? (Map<String, Object>) raw : null;
}
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */ /** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
private static List<String> splitAddressLines(String raw) { private static List<String> splitAddressLines(String raw) {
List<String> lines = new ArrayList<>(); List<String> lines = new ArrayList<>();
@@ -126,6 +143,7 @@ public class PortalSiteSnapshot {
copy.setThemeParams(themeParams); copy.setThemeParams(themeParams);
copy.setThemeVarsCss(themeVarsCss); copy.setThemeVarsCss(themeVarsCss);
copy.setCustomCss(customCss); copy.setCustomCss(customCss);
copy.setHomeLayout(homeLayout);
copy.setFooterAddressLines(footerAddressLines); copy.setFooterAddressLines(footerAddressLines);
return copy; return copy;
} }
@@ -39,6 +39,7 @@ public class SiteService {
private final CmsAuthProperties authProperties; private final CmsAuthProperties authProperties;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry; private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
private final com.yunhai.cms.repository.CmsCategoryRepository categoryRepository;
/** /**
* B2 附件引用:站点配置 logo/favicon 回写场景引用。 * B2 附件引用:站点配置 logo/favicon 回写场景引用。
@@ -59,6 +60,16 @@ public class SiteService {
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params"))); java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500; private static final int CONFIG_VALUE_MAX_LEN = 500;
/** home_layout(docs/16 §4.6)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
"article_list", "ticker", "gallery", "links")));
private static final int HOME_LAYOUT_MAX_BLOCKS = 30;
private static final int HOME_LAYOUT_MAX_BLOCK_KEYS = 32;
private static final int HOME_LAYOUT_MAX_TEXT = 100;
private static final int HOME_LAYOUT_SIZE_MIN = 1;
private static final int HOME_LAYOUT_SIZE_MAX = 20;
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */ /** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240; private static final int CUSTOM_CSS_MAX_LEN = 10240;
private static final List<String> CUSTOM_CSS_BLACKLIST = private static final List<String> CUSTOM_CSS_BLACKLIST =
@@ -162,7 +173,7 @@ public class SiteService {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme); throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
} }
site.setTheme(resolvedTheme); site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson()); validateConfigJson(req.getConfigJson(), site.getId());
site.setConfigJson(mergeThemeParams(req, resolvedTheme)); site.setConfigJson(mergeThemeParams(req, resolvedTheme));
site.setStatus(req.getStatus() == null ? 1 : req.getStatus()); site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site); site = siteRepository.save(site);
@@ -289,10 +300,10 @@ public class SiteService {
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线; * ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL / * ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。 * logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行—— * 系统托管键 theme_params 直通;home_layout 走结构校验(docs/16 §4.6)——
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。 * 此前「非字符串值原样放行」的口子收紧到该键;其余非字符串值(wechat 等预留对象)不校验原样放行。
*/ */
private void validateConfigJson(String configJson) { private void validateConfigJson(String configJson, Integer siteId) {
if (!StringUtils.hasText(configJson)) { if (!StringUtils.hasText(configJson)) {
return; return;
} }
@@ -315,6 +326,10 @@ public class SiteService {
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)"); "站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
} }
JsonNode value = entry.getValue(); JsonNode value = entry.getValue();
if ("home_layout".equals(key)) {
validateHomeLayout(value, siteId);
return;
}
if (!value.isTextual()) { if (!value.isTextual()) {
return; return;
} }
@@ -363,6 +378,79 @@ public class SiteService {
}); });
} }
/**
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键;
* type 白名单(article_list/ticker/gallery/links)、size 1~20、字符串字段(title/subtitle)≤100;
* categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
*/
private void validateHomeLayout(JsonNode node, Integer siteId) {
if (!node.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局必须是 JSON 对象({version, blocks})");
}
if (node.has("version") && !node.get("version").isInt()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 version 必须是整数");
}
JsonNode blocks = node.get("blocks");
if (blocks == null || !blocks.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 blocks 必须是数组");
}
if (blocks.size() > HOME_LAYOUT_MAX_BLOCKS) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块数量不能超过 " + HOME_LAYOUT_MAX_BLOCKS + " 个");
}
Set<Integer> referencedIds = new java.util.HashSet<>();
for (JsonNode block : blocks) {
if (!block.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块必须是对象");
}
if (block.size() > HOME_LAYOUT_MAX_BLOCK_KEYS) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局单个区块键数不能超过 " + HOME_LAYOUT_MAX_BLOCK_KEYS + " 个");
}
JsonNode type = block.get("type");
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText()));
}
JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
|| size.intValue() > HOME_LAYOUT_SIZE_MAX)) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块条数须在 " + HOME_LAYOUT_SIZE_MIN + "~" + HOME_LAYOUT_SIZE_MAX + " 之间");
}
for (String textKey : new String[]{"title", "subtitle"}) {
JsonNode text = block.get(textKey);
if (text != null && (!text.isTextual() || text.asText().length() > HOME_LAYOUT_MAX_TEXT)) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块 " + textKey + " 须为不超过 " + HOME_LAYOUT_MAX_TEXT + " 字的文本");
}
}
JsonNode categoryIds = block.get("categoryIds");
if (categoryIds != null) {
if (!categoryIds.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须是数组");
}
for (JsonNode id : categoryIds) {
if (!id.isNumber() || id.intValue() <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须为正整数");
}
referencedIds.add(id.intValue());
}
}
}
if (!referencedIds.isEmpty()) {
if (siteId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点尚未创建,首页布局不能引用栏目");
}
// 防跨站注入:引用的栏目必须属于本站点
List<com.yunhai.cms.entity.CmsCategory> cats = categoryRepository.findAllById(referencedIds);
for (com.yunhai.cms.entity.CmsCategory c : cats) {
if (!siteId.equals(c.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不属于本站点的栏目(id=" + c.getId() + ")");
}
}
if (cats.size() < referencedIds.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不存在的栏目");
}
}
}
/** /**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝 * custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、 * {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
@@ -21,6 +21,16 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) --> <!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}"> <section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2> <h2 class="section-title">头条推荐</h2>
@@ -49,6 +59,7 @@
</ul> </ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p> <p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -0,0 +1,150 @@
<!--
campus-glow 主题首页区块 fragment(docs/16 §4.4 fragment 化):
news_grid/media_focus/card 三形态由原 home.html 内联段抽出(标记与样式不变,主题 main.css 继续承载);
ticker/gallery 为布局模式专用 fragment(回落模式仍走 home.html 原内联段,数据源 topArticles/galleryBanners)。
契约:块字段与回落 categoryBlocks 同构(category.name 取标题);布局块 background 为 Boolean
开关(未设时回退旧键 news_bg_image 无条件生效),布局块 category.name=区块标题。
-->
<!-- 形态一:双栏新闻卡(homeStyle=news_grid)——全幅背景 + 毛玻璃卡 + 双栏日期列表
(background 为布局块可选键,回落块无此键——SpEL 对 Map 缺失键点号访问会抛错,用 ['key'] 括号形式) -->
<section class="cg-newshot" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="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:fragment="media_focus(block)" 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>
<!-- 形态三:默认卡(featured 首条 + 行式列表) -->
<section class="cg-block" th:fragment="card(block)" aria-label="栏目区块">
<div class="cg-container">
<div class="cg-block-card">
<div class="cg-sec-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="cg-more-text" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<div class="cg-block-cols" th:if="${!#lists.isEmpty(block.items)}">
<a class="cg-block-featured" th:with="feat=${block.items[0]}" th:href="${url.article(feat.id)}">
<span class="cg-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="cg-feat-body">
<span class="cg-feat-title" th:text="${feat.title}">头条标题</span>
<span class="cg-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="cg-feat-meta">
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
<ul class="cg-block-list">
<li th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<div class="cg-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="cg-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
<!-- 布局模式:滚动速递(数据=渲染端装配进 block.items) -->
<section class="cg-ticker" th:fragment="ticker(block)" aria-label="要闻速递" th:if="${!#lists.isEmpty(block.items)}">
<div class="cg-container cg-ticker-inner">
<span class="cg-ticker-label" th:text="${block.title}">要闻速递</span>
<div class="cg-ticker-viewport">
<div class="cg-ticker-track">
<a class="cg-ticker-item" th:each="item : ${block.items}" 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 : ${block.items}" 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>
<!-- 布局模式:线上展厅(数据=block.items 轮播位 gallery;标题/副题=渲染端已合并旧键缺省) -->
<section class="cg-gallery" th:fragment="gallery(block)" aria-label="线上展厅" th:if="${!#lists.isEmpty(block.items)}">
<div class="cg-container">
<div class="cg-gallery-head">
<h2 th:text="${block.title}">线上展厅</h2>
<p class="cg-gallery-sub" th:text="${block.subtitle}">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 : ${block.items}" 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(block.items) > 1}">‹</button>
<button class="cg-gallery-arrow next" type="button" aria-label="下一页" th:if="${#lists.size(block.items) > 1}">›</button>
</div>
</div>
</section>
@@ -5,7 +5,7 @@
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header> <header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="cg-home" id="main-content"> <main class="cg-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize) --> <!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize)——页面级固定槽位,布局模式同样保留 -->
<section class="cg-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片"> <section class="cg-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="cg-banner-slides"> <div class="cg-banner-slides">
<a class="cg-banner-slide" th:each="b, stat : ${banners}" <a class="cg-banner-slide" th:each="b, stat : ${banners}"
@@ -23,151 +23,81 @@
</div> </div>
</section> </section>
<!-- 要闻速递横条:置顶文章横向无缝滚动(hover 暂停) --> <!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染。
<section class="cg-ticker" th:if="${!#lists.isEmpty(topArticles)}" aria-label="要闻速递"> article_list 三种形态走主题自有 fragment(compact/image_wall 与未实现类型落 common 兜底骨架)。
<div class="cg-container cg-ticker-inner"> 注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
<span class="cg-ticker-label">要闻速递</span> <th:block th:if="${layoutBlocks != null}" th:each="block : ${layoutBlocks}">
<div class="cg-ticker-viewport"> <th:block th:switch="${block.type}">
<div class="cg-ticker-track"> <th:block th:case="'article_list'">
<a class="cg-ticker-item" th:each="item : ${topArticles}" th:href="${url.article(item.id)}"> <th:block th:switch="${block.homeStyle}">
<span class="cg-ticker-dot" aria-hidden="true"></span> <th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<span th:text="${item.title}">要闻标题</span> <th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
</a> <th:block th:case="'compact'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCompact(${block})}" /></th:block>
<a class="cg-ticker-item" th:each="item : ${topArticles}" th:href="${url.article(item.id)}" aria-hidden="true"> <th:block th:case="'image_wall'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbImageWall(${block})}" /></th:block>
<span class="cg-ticker-dot" aria-hidden="true"></span> <th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
<span th:text="${item.title}">要闻标题</span> </th:block>
</a> </th:block>
</div> <th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
</div> <th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
</div> <th:block th:case="'links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbLinks(${block})}" /></th:block>
</section> <th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block>
<!-- 一级栏目区块(docs/12):按栏目 homeStyle 三种形态渲染,栏目顺序即首页板块顺序 --> <!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染(布局模式下渲染端数据源已清空,双保险不漏段) -->
<th:block th:each="block : ${categoryBlocks}"> <th:block th:unless="${layoutBlocks != null}">
<!-- 形态一:双栏新闻卡(homeStyle=news_grid)——全幅背景 + 毛玻璃卡 + 双栏日期列表 --> <!-- 要闻速递横条:置顶文章横向无缝滚动(hover 暂停) -->
<section class="cg-newshot" th:if="${block.homeStyle == 'news_grid'}" <section class="cg-ticker" th:if="${!#lists.isEmpty(topArticles)}" aria-label="要闻速递">
th:with="newsBg=${site.config['news_bg_image']}" aria-label="栏目区块"> <div class="cg-container cg-ticker-inner">
<div class="cg-newshot-bg" th:if="${!#strings.isEmpty(newsBg)}" <span class="cg-ticker-label">要闻速递</span>
th:style="|background-image:url(${url.upload(newsBg)})|"></div> <div class="cg-ticker-viewport">
<div class="cg-container"> <div class="cg-ticker-track">
<div class="cg-newshot-card"> <a class="cg-ticker-item" th:each="item : ${topArticles}" th:href="${url.article(item.id)}">
<div class="cg-sec-head"> <span class="cg-ticker-dot" aria-hidden="true"></span>
<h2 th:text="${block.category.name}">校园时讯</h2> <span th:text="${item.title}">要闻标题</span>
<a class="cg-more-circle" th:href="${block.moreUrl}" aria-label="更多"> </a>
<svg viewBox="0 0 24 24" width="18" height="18" focusable="false" aria-hidden="true"> <a class="cg-ticker-item" th:each="item : ${topArticles}" th:href="${url.article(item.id)}" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" <span class="cg-ticker-dot" aria-hidden="true"></span>
stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/> <span th:text="${item.title}">要闻标题</span>
</svg>
</a> </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> </div>
</div> </div>
</section> </section>
<!-- 形态二:媒体聚焦列表(homeStyle=media_focus)——来源徽标 + 标题双栏 --> <!-- 一级栏目区块(docs/12):按栏目 homeStyle 三种形态渲染,栏目顺序即首页板块顺序 -->
<section class="cg-media" th:if="${block.homeStyle == 'media_focus'}" aria-label="栏目区块"> <th:block th:each="block : ${categoryBlocks}">
<div class="cg-container"> <th:block th:switch="${block.homeStyle}">
<div class="cg-media-card"> <th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<div class="cg-sec-head"> <th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<h2 th:text="${block.category.name}">媒体聚焦</h2> <th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
<a class="cg-more-circle" th:href="${block.moreUrl}" aria-label="更多"> </th:block>
<svg viewBox="0 0 24 24" width="18" height="18" focusable="false" aria-hidden="true"> </th:block>
<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 首条 + 行式列表,旧栏目保持常规呈现 --> <!-- 线上展厅(cms_banner position=gallery;设计尺寸 800×520;每行列数由主题参数 galleryCols 控制) -->
<section class="cg-block" th:if="${block.homeStyle == null or block.homeStyle == ''}" aria-label="栏目区块"> <section class="cg-gallery" th:if="${!#lists.isEmpty(galleryBanners)}" aria-label="线上展厅">
<div class="cg-container"> <div class="cg-container">
<div class="cg-block-card"> <div class="cg-gallery-head">
<div class="cg-sec-head"> <h2 th:text="${site.config['gallery_title'] ?: '线上展厅'}">线上展厅</h2>
<h2 th:text="${block.category.name}">栏目名</h2> <p class="cg-gallery-sub" th:text="${site.config['gallery_subtitle'] ?: 'Online Exhibition Hall'}">Online Exhibition Hall</p>
<a class="cg-more-text" th:href="${block.moreUrl}">更多 &gt;</a> </div>
</div> <div class="cg-gallery-stage"
<div class="cg-block-cols" th:if="${!#lists.isEmpty(block.items)}"> th:classappend="${'cg-gallery-cols-' + (themeParams['galleryCols'] ?: '4')}"
<a class="cg-block-featured" th:with="feat=${block.items[0]}" th:href="${url.article(feat.id)}"> th:attr="data-cols=${themeParams['galleryCols'] ?: '4'}">
<span class="cg-feat-cover" th:if="${feat.coverImage != null}"> <div class="cg-gallery-track">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}"> <a class="cg-gallery-item" th:each="g : ${galleryBanners}" th:href="${g.linkUrl}"
</span> th:title="${g.title}">
<span class="cg-feat-body"> <img th:src="${g.imageUrl}" th:alt="${g.title}">
<span class="cg-feat-title" th:text="${feat.title}">头条标题</span> <span class="cg-gallery-cap" th:if="${!#strings.isEmpty(g.title)}"
<span class="cg-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${g.title}">作品标题</span>
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> </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>
<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>
</div> </div>
</section> </section>
</th:block> </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> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -21,6 +21,16 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) --> <!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}"> <section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2> <h2 class="section-title">头条推荐</h2>
@@ -49,6 +59,7 @@
</ul> </ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p> <p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -0,0 +1,245 @@
<!--
通用兜底区块骨架(docs/16 §4.4):主题未实现某区块类型时由本文件渲染(结构完整、样式克制)。
任何主题 home.html 的布局分支都可引用:~{'themes/common/fragments/home-blocks' :: render(block)}。
样式随 fragment 内联携带——themes/common 不注册为皮肤包(无 theme.json),主题资产通道不服务该目录;
颜色回落站点主题变量(--primary 等 themeVarsCss 注入),无变量时用内置缺省。
契约:block 为渲染端组装的同构 Map——type/title/category.name/homeStyle/items/moreUrl/subtitle/
background/source/linkGroups,与回落模式 categoryBlocks 的块字段兼容。
-->
<th:block th:fragment="style">
<style>
.plb-section{padding:26px 16px}
.plb-container{max-width:1200px;margin:0 auto}
.plb-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin:0 0 14px}
.plb-head h2{margin:0;font-size:20px;line-height:1.3;padding-left:10px;border-left:4px solid var(--primary,#2d8cf0);color:#1f2d3d}
.plb-sub{margin:2px 0 0;color:#8a94a6;font-size:12px;letter-spacing:.12em}
.plb-more{font-size:13px;color:var(--primary,#2d8cf0);text-decoration:none;white-space:nowrap}
.plb-more:hover{text-decoration:underline}
.plb-list{list-style:none;margin:0;padding:0}
.plb-list li{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px dashed rgba(0,0,0,.08);font-size:14px}
.plb-list li a{flex:1;min-width:0;color:#333;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-list li a:hover{color:var(--primary,#2d8cf0)}
.plb-list time{color:#999;font-size:12px;flex:none}
.plb-badge{flex:none;font-size:11px;color:#fff;background:var(--primary,#2d8cf0);border-radius:3px;padding:1px 5px}
.plb-src{flex:none;font-size:11px;color:var(--primary,#2d8cf0);border:1px solid currentColor;border-radius:3px;padding:1px 6px}
.plb-empty{color:#98a2b3;font-size:13px;padding:26px 0;text-align:center}
.plb-card{background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:20px 24px;box-shadow:0 4px 18px rgba(15,34,66,.05)}
.plb-feat{display:flex;gap:16px;margin:0 0 12px;padding:0 0 12px;border-bottom:1px solid rgba(0,0,0,.06);text-decoration:none;color:#333}
.plb-feat img{width:200px;height:120px;object-fit:cover;border-radius:6px;flex:none}
.plb-feat .t{display:block;font-size:16px;font-weight:600;color:#1f2d3d;margin:0 0 6px}
.plb-feat .s{display:block;color:#8a94a6;font-size:13px;line-height:1.6;margin:0 0 6px}
.plb-feat .m{color:#999;font-size:12px}
.plb-grid2{columns:2;column-gap:40px}
.plb-grid2 li{break-inside:avoid}
.plb-newshot{position:relative;padding:30px 0;background:#f5f7fb}
.plb-newshot-bg{position:absolute;inset:0;background-size:cover;background-position:center}
.plb-newshot-card{position:relative;background:rgba(255,255,255,.94);border-radius:10px;padding:22px 26px;box-shadow:0 8px 30px rgba(15,34,66,.08)}
.plb-imagewall{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;list-style:none;margin:0;padding:0}
.plb-imagewall a{text-decoration:none;color:#333}
.plb-imagewall img{width:100%;height:120px;object-fit:cover;border-radius:6px;display:block}
.plb-imagewall .t{display:block;font-size:13px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-ticker{background:var(--primary,#2d8cf0);color:#fff;padding:10px 16px;font-size:14px}
.plb-ticker-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:14px}
.plb-ticker-label{flex:none;font-weight:600;letter-spacing:.08em}
.plb-ticker-list{display:flex;gap:26px;list-style:none;margin:0;padding:0;overflow:hidden}
.plb-ticker-list a{color:#fff;text-decoration:none;opacity:.92}
.plb-ticker-list a:hover{opacity:1;text-decoration:underline}
.plb-gallery-head{text-align:center;margin:0 0 18px}
.plb-gallery-head h2{margin:0;font-size:22px;color:#1f2d3d}
.plb-gallery-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;list-style:none;margin:0;padding:0}
.plb-gallery-grid img{width:100%;height:150px;object-fit:cover;border-radius:8px;display:block}
.plb-gallery-grid .t{display:block;font-size:13px;color:#51607a;margin-top:6px;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-gallery-grid a{text-decoration:none}
.plb-links{display:flex;flex-wrap:wrap;gap:10px;list-style:none;margin:0;padding:0}
.plb-links a{display:inline-block;font-size:13px;color:#51607a;text-decoration:none;border:1px solid rgba(0,0,0,.1);border-radius:16px;padding:4px 14px;background:#fff}
.plb-links a:hover{color:var(--primary,#2d8cf0);border-color:var(--primary,#2d8cf0)}
@media (max-width:768px){
.plb-grid2{grid-template-columns:1fr}
.plb-imagewall{grid-template-columns:repeat(2,1fr)}
.plb-gallery-grid{grid-template-columns:repeat(2,1fr)}
.plb-feat img{width:120px;height:80px}
}
</style>
</th:block>
<!-- 类型分发:主题 home.html 的布局分支唯一入口;article_list 按 homeStyle 细分,未知类型落卡片兜底。
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素,
否则未命中的 case 分支也会求值 fragment 表达式(缺键 Map 访问直接抛错)。 -->
<th:block th:fragment="render(block)">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
<th:block th:case="'news_grid'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbNewsGrid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbMediaFocus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCompact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbImageWall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCard(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbTicker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbGallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbLinks(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCard(${block})}" /></th:block>
</th:block>
</th:block>
<!-- 区块头(标题/副题 + 更多链接;副题/更多为可选键——SpEL 对 Map 缺失键的属性访问会抛错,一律用 ['key'] 括号形式) -->
<div class="plb-head" th:fragment="head(block)">
<div>
<h2 th:text="${block.title}">区块标题</h2>
<p class="plb-sub" th:if="${!#strings.isEmpty(block['subtitle'])}" th:text="${block['subtitle']}">副题</p>
</div>
<a class="plb-more" th:if="${!#strings.isEmpty(block['moreUrl'])}" th:href="${block['moreUrl']}">更多 &gt;</a>
</div>
<!-- 空态 -->
<div class="plb-empty" th:fragment="empty(block)">
<p>该区块暂无内容</p>
</div>
<!-- 形态:默认卡(头条 + 行式列表) -->
<section class="plb-section plb-card" th:fragment="plbCard(block)" aria-label="栏目区块">
<div class="plb-container">
<div class="plb-card">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<a class="plb-feat" th:if="${!#lists.isEmpty(block.items)}" th:with="feat=${block.items[0]}"
th:href="${url.article(feat.id)}">
<img th:if="${feat.coverImage != null}" th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
<span>
<span class="t" th:text="${feat.title}">头条标题</span>
<span class="s" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
<span class="m">
<span class="plb-badge" th:if="${feat.topmost == 1}">置顶</span>
<time th:if="${feat.publishTime != null}" th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
<ul class="plb-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="plb-badge" th:if="${item.topmost == 1}">置顶</span>
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
</div>
</div>
</section>
<!-- 形态:纯文字列表(compact) -->
<section class="plb-section plb-compact" th:fragment="plbCompact(block)" aria-label="栏目区块">
<div class="plb-container">
<div class="plb-card">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a class="plb-row-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="plb-badge" th:if="${item.topmost == 1}">置顶</span>
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
</div>
</div>
</section>
<!-- 形态:双栏新闻卡(news_grid;background 开关取站点旧键背景图;双栏用 CSS 多列流式分栏) -->
<section class="plb-newshot" th:fragment="plbNewsGrid(block)" aria-label="栏目区块"
th:with="newsBg=${block.background ? site.config['news_bg_image'] : null}">
<div class="plb-newshot-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="plb-container">
<div class="plb-newshot-card">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-list plb-grid2" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="plb-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
</div>
</div>
</section>
<!-- 形态:媒体聚焦(media_focus;来源徽标) -->
<section class="plb-section plb-media-focus" th:fragment="plbMediaFocus(block)" aria-label="栏目区块">
<div class="plb-container">
<div class="plb-card">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<span class="plb-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="plb-badge" th:if="${item.topmost == 1}">置顶</span>
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
</div>
</div>
</section>
<!-- 形态:图片墙(image_wall;仅取有封面文章,不足即少几格) -->
<section class="plb-section plb-image-wall" th:fragment="plbImageWall(block)" aria-label="栏目区块">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-imagewall" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
<span class="t" th:text="${item.title}">文章标题</span>
</a>
</li>
</ul>
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
</div>
</section>
<!-- 区块:滚动速递(ticker;source=top/category 数据由渲染端装配进 items) -->
<section class="plb-ticker" th:fragment="plbTicker(block)" aria-label="滚动速递" th:if="${!#lists.isEmpty(block.items)}">
<div class="plb-ticker-inner">
<span class="plb-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="plb-ticker-list">
<li th:each="item : ${block.items}">
<a class="plb-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 区块:图片画廊(gallery;轮播位 gallery 数据通道不变) -->
<section class="plb-section" th:fragment="plbGallery(block)" aria-label="图片画廊" th:if="${!#lists.isEmpty(block.items)}">
<div class="plb-container">
<div class="plb-gallery-head">
<h2 th:text="${block.title}">线上展厅</h2>
<p class="plb-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">Online Exhibition Hall</p>
</div>
<ul class="plb-gallery-grid">
<li th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}">
<span class="t" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</div>
</section>
<!-- 区块:链接组(links;友情链接分组,group 由渲染端过滤) -->
<section class="plb-section" th:fragment="plbLinks(block)" aria-label="链接组">
<div class="plb-container">
<div class="plb-card">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="plb-sub" th:if="${!#strings.isEmpty(grp.groupName)}" style="margin:8px 0" th:text="${grp.groupName}">分组名</p>
<ul class="plb-links">
<li th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
</li>
</ul>
</th:block>
</div>
</div>
</section>
@@ -23,6 +23,16 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 头条区:头条推荐(置顶优先) + 图片新闻(有封面文章) --> <!-- 头条区:头条推荐(置顶优先) + 图片新闻(有封面文章) -->
<section class="section container" aria-label="头条与图片新闻"> <section class="section container" aria-label="头条与图片新闻">
<div class="headline-grid"> <div class="headline-grid">
@@ -110,6 +120,7 @@
</div> </div>
</div> </div>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -21,6 +21,16 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) --> <!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}"> <section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2> <h2 class="section-title">头条推荐</h2>
@@ -49,6 +59,7 @@
</ul> </ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p> <p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -21,6 +21,16 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) --> <!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}"> <section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2> <h2 class="section-title">头条推荐</h2>
@@ -49,6 +59,7 @@
</ul> </ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p> <p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -23,7 +23,16 @@
</div> </div>
</section> </section>
<!-- 头条区:头条推荐(置顶优先) + 图片新闻(有封面文章) --> <!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<section class="sp-section sp-container" aria-label="头条与图片新闻"> <section class="sp-section sp-container" aria-label="头条与图片新闻">
<div class="sp-headline"> <div class="sp-headline">
<div class="sp-headline-card" th:if="${!#lists.isEmpty(topArticles)}"> <div class="sp-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
@@ -116,6 +125,7 @@
</div> </div>
</div> </div>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -21,6 +21,16 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) --> <!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}"> <section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2> <h2 class="section-title">头条推荐</h2>
@@ -49,6 +59,7 @@
</ul> </ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p> <p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section> </section>
</th:block>
</main> </main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer> <footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -0,0 +1,183 @@
package com.yunhai.cms.portal;
import com.yunhai.cms.entity.CmsArticle;
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/16 主线B §4.4/§4.5):
* ① 无 home_layout = 回落现状(categoryBlocks 原样,零迁移);
* ② 布局模式按 blocks 序列渲染(common 兜底骨架 plb-*)——顺序、多栏目聚合、size 上限、
* visible:false 保序下线、ticker 双数据源、gallery 旧键默认值、links 分组;
* ③ campus-glow 主题自有 fragment(news_grid)在布局模式下生效;
* ④ 非法 home_layout 容错回落(脏数据不打挂门户)。
*/
@DisplayName("Portal HomeLayout - 首页布局渲染(docs/16)")
class PortalHomeLayoutIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
private Integer catNews;
private Integer catNotice;
@BeforeEach
void seed() {
purgeTables();
CmsSite site = persistSite("layhome", 1, "布局测试门户");
Integer siteId = site.getId();
catNews = persistCategory(siteId, 0, "校园时讯", CategoryTypeEnum.LIST).getId();
catNotice = persistCategory(siteId, 0, "通知公告", CategoryTypeEnum.LIST).getId();
persistCategory(siteId, 0, "学校概况", CategoryTypeEnum.SINGLE);
for (int i = 1; i <= 10; i++) {
persistArticle(siteId, catNews, "时讯文章" + i, ArticleStatusEnum.PUBLISHED, nowMinusSeconds(100L * i));
}
// 通知文章带封面(focus-card 仅渲染有封面的已发布文章);时间不改变 ticker/compact 的最新排序
CmsArticle n1 = persistArticle(siteId, catNotice, "通知一", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(50));
n1.setCoverImage("/upload/cms/cover-n1.png");
articleRepository.saveAndFlush(n1);
CmsArticle n2 = persistArticle(siteId, catNotice, "通知二", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(40));
n2.setCoverImage("/upload/cms/cover-n2.png");
articleRepository.saveAndFlush(n2);
persistArticle(siteId, catNotice, "通知三", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(30));
persistBanner(siteId, "home", "首页轮播", null, null);
persistBanner(siteId, "gallery", "展厅作品一", null, null);
persistLink(siteId, "常用链接", "教育部");
persistLink(siteId, "校内导航", "图书馆");
clearCaches();
}
@AfterEach
void clean() {
purgeTables();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
private void setConfig(String code, String configJson) {
siteRepository.findByCode(code).ifPresent(s -> {
s.setConfigJson(configJson);
siteRepository.saveAndFlush(s);
});
clearCaches();
}
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;
}
@Test
@DisplayName("无 home_layout:回落现状渲染(categoryBlocks 原样,无布局骨架 markup)")
void fallback_withoutLayout() throws Exception {
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(html).contains("category-block").contains("校园时讯").contains("通知公告");
assertThat(html).contains("headline-list").contains("focus-card"); // 头条/图片新闻段保留
assertThat(html).doesNotContain("plb-section");
}
@Test
@DisplayName("布局模式:按序列渲染(ticker置顶 → 双栏目聚合size2 → gallery旧键默认题 → links分组),隐藏块跳过")
void layoutMode_rendersSequence() throws Exception {
setConfig("layhome", "{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"ticker\",\"title\":\"要闻速递\",\"size\":2},"
+ "{\"id\":\"b2\",\"type\":\"article_list\",\"title\":\"要闻公告\",\"categoryIds\":[" + catNews + "," + catNotice + "],\"style\":\"compact\",\"size\":2},"
+ "{\"id\":\"b3\",\"type\":\"article_list\",\"title\":\"隐藏块\",\"categoryIds\":[" + catNews + "],\"visible\":false},"
+ "{\"id\":\"b4\",\"type\":\"gallery\"},"
+ "{\"id\":\"b5\",\"type\":\"links\",\"group\":\"常用链接\"}"
+ "]}}");
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
// 顶部轮播(页面级固定槽位)保留
assertThat(html).contains("首页轮播");
// 回落段清空:头条/图片新闻/栏目区块不渲染
assertThat(html).doesNotContain("headline-list").doesNotContain("category-block");
// ticker:置顶优先+发布时间倒序取 size=2 条(无置顶 → 最新两篇)
assertThat(html).contains("plb-ticker").contains("要闻速递");
assertThat(count(html, "plb-ticker-item")).as("ticker 取 size=2 条").isEqualTo(2);
// article_list:多栏目聚合 + compact 形态 + size=2;标题用区块 title 而非栏目名
assertThat(html).contains("plb-section").contains("plb-compact");
assertThat(html).contains("要闻公告");
assertThat(html).doesNotContain("隐藏块");
assertThat(count(html, "plb-row-title")).as("聚合双栏目取 size=2 条").isEqualTo(2);
assertThat(html).contains("通知三").contains("通知二");
// gallery:区块未设标题 → 旧键缺省「线上展厅」;副题缺省英文
assertThat(html).contains("plb-gallery").contains("线上展厅").contains("Online Exhibition Hall");
assertThat(html).contains("展厅作品一");
// links:仅指定分组(页脚友情链接为页面级内容,不受区块过滤影响——按区块列表计数断言)
assertThat(html).contains("plb-links").contains("教育部");
assertThat(count(html, "<ul class=\"plb-links\">")).as("links 区块仅渲染指定分组").isEqualTo(1);
}
@Test
@DisplayName("布局模式:article_list 未设标题时回退第一个栏目名;gallery 区块标题覆盖旧键")
void layoutMode_titleFallbacks() throws Exception {
setConfig("layhome", "{\"gallery_title\":\"旧展厅标题\",\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"article_list\",\"categoryIds\":[" + catNews + "],\"size\":1},"
+ "{\"id\":\"b2\",\"type\":\"gallery\",\"title\":\"新展厅\",\"subtitle\":\"New Hall\"}"
+ "]}}");
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
// 区块无标题 → 第一个栏目名
assertThat(html).contains("校园时讯");
// 区块标题覆盖旧键
assertThat(html).contains("新展厅").contains("New Hall");
assertThat(html).doesNotContain("旧展厅标题");
}
@Test
@DisplayName("campus-glow 布局模式:article_list style=news_grid 走主题自有 fragment(背景+双栏)")
void glowTheme_layoutWithNewsGrid() throws Exception {
setConfig("layhome", "{\"news_bg_image\":\"/cms/upload/campus.jpg\",\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"article_list\",\"title\":\"校园要闻\",\"categoryIds\":[" + catNews + "],\"style\":\"news_grid\",\"size\":16,\"background\":true}"
+ "]}}");
siteRepository.findByCode("layhome").ifPresent(s -> {
s.setTheme("campus-glow");
siteRepository.saveAndFlush(s);
});
clearCaches();
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
// 主题自有 fragment:cg-newshot 形态 + 区块标题覆盖 + 背景图(开关生效)
assertThat(html).contains("cg-newshot").contains("cg-news-grid");
assertThat(html).contains("校园要闻");
assertThat(html).contains("campus.jpg");
assertThat(count(html, "cg-news-title")).isEqualTo(10);
}
@Test
@DisplayName("非法 home_layout(blocks 非数组/type 白名单外):容错回落现状,门户不打挂")
void invalidLayout_fallsBack() throws Exception {
setConfig("layhome", "{\"home_layout\":{\"blocks\":\"bad\"},\"logo\":\"/a.png\"}");
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(html).contains("category-block").doesNotContain("plb-section");
setConfig("layhome", "{\"home_layout\":{\"blocks\":[{\"type\":\"richtext\"}]}}");
html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
// 未知 type 块被跳过;其余仍为布局模式(空 blocks 有效,但该配置 blocks=[未知] → 序列为空 → 仅骨架)
assertThat(html).doesNotContain("richtext");
}
}
@@ -0,0 +1,143 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.model.dto.SiteSaveRequest;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* home_layout 结构校验(docs/16 §4.6):validateConfigJson 对该键收紧——
* 对象结构 / blocks 数组 ≤30 / type 白名单 / size 1~20 / 文本 ≤100 /
* categoryIds 正整数且属于本站栏目树(防跨站注入);其余非字符串键维持原样放行不回归。
*/
@DisplayName("SiteService HomeLayout - home_layout 结构校验(docs/16 §4.6)")
class SiteServiceHomeLayoutIT extends CmsIntegrationTestBase {
@Autowired
private SiteService siteService;
@AfterEach
void clean() {
purgeTables();
}
private SiteSaveRequest baseReq(String code) {
SiteSaveRequest req = new SiteSaveRequest();
req.setCode(code);
req.setSchId(1);
req.setSchName("第一中学");
req.setName(code + "门户");
return req;
}
@Test
@DisplayName("合法 home_layout 保存成功并原样落库")
void save_validHomeLayout() {
SiteSaveRequest req = baseReq("lay1");
req.setConfigJson("{\"home_layout\":{\"version\":1,\"blocks\":[" +
"{\"id\":\"b1\",\"type\":\"ticker\",\"title\":\"要闻速递\",\"size\":6}," +
"{\"id\":\"b2\",\"type\":\"article_list\",\"title\":\"校园时讯\",\"categoryIds\":[],\"style\":\"news_grid\",\"size\":16}" +
"]}}");
SiteVo vo = siteService.save(TestUsers.superAdmin(), req);
assertThat(siteService.configValue(siteService.getByCode("lay1"), "home_layout")).isNotNull();
assertThat(vo.getId()).isNotNull();
}
@Test
@DisplayName("结构非法被拒:非对象 / blocks 非数组 / 未知 type / size 越界 / 标题超长 / 区块超量")
void save_invalidStructuresRejected() {
CmsSite site = persistSite("lay2", 1, "校验站");
assertReject(site.getId(), "{\"home_layout\":\"bad\"}", "JSON 对象");
assertReject(site.getId(), "{\"home_layout\":{\"blocks\":\"bad\"}}", "数组");
assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"richtext\"}]}}", "类型不合法");
assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"ticker\",\"size\":0}]}}", "条数");
assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"ticker\",\"size\":21}]}}", "条数");
StringBuilder longTitle = new StringBuilder();
for (int i = 0; i < 101; i++) {
longTitle.append("长");
}
assertReject(site.getId(),
"{\"home_layout\":{\"blocks\":[{\"type\":\"gallery\",\"title\":\"" + longTitle + "\"}]}}", "100");
StringBuilder many = new StringBuilder("{\"home_layout\":{\"blocks\":[");
for (int i = 0; i < 31; i++) {
many.append("{\"type\":\"links\"}").append(i < 30 ? "," : "");
}
many.append("]}}");
assertReject(site.getId(), many.toString(), "30");
// 其余非字符串键(wechat 预留对象)仍原样放行,不回归
SiteSaveRequest ok = new SiteSaveRequest();
ok.setId(site.getId());
ok.setCode("lay2");
ok.setName("校验站");
ok.setSchId(1);
ok.setConfigJson("{\"wechat\":{\"appid\":\"x\"}}");
assertThat(siteService.save(TestUsers.superAdmin(), ok)).isNotNull();
}
@Test
@DisplayName("categoryIds:新建站拒绝引用;跨站栏目拒绝;本站合法栏目通过")
void save_categoryIdsOwnership() {
CmsSite mine = persistSite("lay3", 1, "本校站");
persistSite("lay4", 2, "他校站");
CmsCategory mineCat = persistCategory(mine.getId(), 0, "本校栏目", CategoryTypeEnum.LIST);
CmsCategory otherCat = persistCategory(siteRepository.findByCode("lay4").orElseThrow(IllegalStateException::new).getId(),
0, "他站栏目", CategoryTypeEnum.LIST);
Integer mineCatId = mineCat.getId();
Integer otherCatId = otherCat.getId();
// 新建站点带 categoryIds 拒绝(尚无栏目可引)
SiteSaveRequest create = baseReq("lay5");
create.setConfigJson("{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[" + mineCatId + "]}]}}");
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), create))
.isInstanceOf(BizException.class)
.hasMessageContaining("尚未创建");
// 跨站栏目注入拒绝
assertReject(mine.getId(),
"{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[999999]}]}}", "不存在");
SiteSaveRequest cross = new SiteSaveRequest();
cross.setId(mine.getId());
cross.setCode("lay3");
cross.setName("本校站");
cross.setSchId(1);
cross.setConfigJson("{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":["
+ otherCatId + "]}]}}");
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), cross))
.isInstanceOf(BizException.class)
.hasMessageContaining("不属于本站点");
// 本站合法栏目通过
SiteSaveRequest own = new SiteSaveRequest();
own.setId(mine.getId());
own.setCode("lay3");
own.setName("本校站");
own.setSchId(1);
own.setConfigJson("{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[" + mineCatId + "],\"size\":8}]}}");
assertThat(siteService.save(TestUsers.superAdmin(), own)).isNotNull();
}
private void assertReject(Integer siteId, String configJson, String messagePart) {
SiteSaveRequest req = new SiteSaveRequest();
req.setId(siteId);
req.setCode(siteService.requireManageable(TestUsers.superAdmin(), siteId).getCode());
req.setName("校验站");
req.setSchId(1);
req.setConfigJson(configJson);
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class)
.hasMessageContaining(messagePart);
}
}