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
build / build-push (push) Successful in 52s
This commit is contained in:
@@ -20,6 +20,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
||||
@@ -37,7 +38,9 @@ public class PortalPageRenderer {
|
||||
|
||||
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) {
|
||||
model.addAttribute("url", url);
|
||||
model.addAttribute("site", site);
|
||||
@@ -48,10 +51,20 @@ public class PortalPageRenderer {
|
||||
model.addAttribute("navCategories", site.getCategories());
|
||||
List<Map<String, Object>> banners = activeBanners(site, "home", url);
|
||||
model.addAttribute("banners", banners);
|
||||
List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
|
||||
if (layoutBlocks != null) {
|
||||
// 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
|
||||
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()));
|
||||
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("seoDescription", site.getConfig() == null ? null
|
||||
: site.getConfig().get("seo_description"));
|
||||
@@ -169,10 +182,10 @@ public class PortalPageRenderer {
|
||||
}
|
||||
|
||||
/** 首页置顶文章区:数据库精确取 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(
|
||||
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"))));
|
||||
List<PortalListData.Item> items = new ArrayList<>();
|
||||
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) {
|
||||
PortalListData.Item item = new PortalListData.Item();
|
||||
item.setId(a.getId());
|
||||
|
||||
@@ -63,6 +63,13 @@ public class PortalSiteSnapshot {
|
||||
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
|
||||
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 拆分去空行,装配期派生随快照缓存,
|
||||
* 规避模板内拆分的表达式平台差异)。
|
||||
@@ -84,10 +91,20 @@ 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.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
|
||||
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
|
||||
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 分隔,去空行;空值返回空列表) */
|
||||
private static List<String> splitAddressLines(String raw) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
@@ -126,6 +143,7 @@ public class PortalSiteSnapshot {
|
||||
copy.setThemeParams(themeParams);
|
||||
copy.setThemeVarsCss(themeVarsCss);
|
||||
copy.setCustomCss(customCss);
|
||||
copy.setHomeLayout(homeLayout);
|
||||
copy.setFooterAddressLines(footerAddressLines);
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public class SiteService {
|
||||
private final CmsAuthProperties authProperties;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
|
||||
private final com.yunhai.cms.repository.CmsCategoryRepository categoryRepository;
|
||||
|
||||
/**
|
||||
* B2 附件引用:站点配置 logo/favicon 回写场景引用。
|
||||
@@ -59,6 +60,16 @@ public class SiteService {
|
||||
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
|
||||
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):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
|
||||
private static final int CUSTOM_CSS_MAX_LEN = 10240;
|
||||
private static final List<String> CUSTOM_CSS_BLACKLIST =
|
||||
@@ -162,7 +173,7 @@ public class SiteService {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
|
||||
}
|
||||
site.setTheme(resolvedTheme);
|
||||
validateConfigJson(req.getConfigJson());
|
||||
validateConfigJson(req.getConfigJson(), site.getId());
|
||||
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
|
||||
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||
site = siteRepository.save(site);
|
||||
@@ -289,10 +300,10 @@ public class SiteService {
|
||||
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
|
||||
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
|
||||
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
|
||||
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
|
||||
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
|
||||
* 系统托管键 theme_params 直通;home_layout 走结构校验(docs/16 §4.6)——
|
||||
* 此前「非字符串值原样放行」的口子收紧到该键;其余非字符串值(wechat 等预留对象)不校验原样放行。
|
||||
*/
|
||||
private void validateConfigJson(String configJson) {
|
||||
private void validateConfigJson(String configJson, Integer siteId) {
|
||||
if (!StringUtils.hasText(configJson)) {
|
||||
return;
|
||||
}
|
||||
@@ -315,6 +326,10 @@ public class SiteService {
|
||||
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
|
||||
}
|
||||
JsonNode value = entry.getValue();
|
||||
if ("home_layout".equals(key)) {
|
||||
validateHomeLayout(value, siteId);
|
||||
return;
|
||||
}
|
||||
if (!value.isTextual()) {
|
||||
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;黑名单忽略大小写拒绝
|
||||
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 置顶/头条区(cms_article topmost 优先) -->
|
||||
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<h2 class="section-title">头条推荐</h2>
|
||||
@@ -49,6 +59,7 @@
|
||||
</ul>
|
||||
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<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}">更多 ></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>
|
||||
|
||||
<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="焦点图片">
|
||||
<div class="cg-banner-slides">
|
||||
<a class="cg-banner-slide" th:each="b, stat : ${banners}"
|
||||
@@ -23,6 +23,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染。
|
||||
article_list 三种形态走主题自有 fragment(compact/image_wall 与未实现类型落 common 兜底骨架)。
|
||||
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
|
||||
<th:block th:if="${layoutBlocks != null}" th:each="block : ${layoutBlocks}">
|
||||
<th:block th:switch="${block.type}">
|
||||
<th:block th:case="'article_list'">
|
||||
<th:block th:switch="${block.homeStyle}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="'compact'"><th:block th:replace="~{'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="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
|
||||
<th:block th:case="'links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbLinks(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染(布局模式下渲染端数据源已清空,双保险不漏段) -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 要闻速递横条:置顶文章横向无缝滚动(hover 暂停) -->
|
||||
<section class="cg-ticker" th:if="${!#lists.isEmpty(topArticles)}" aria-label="要闻速递">
|
||||
<div class="cg-container cg-ticker-inner">
|
||||
@@ -44,105 +67,11 @@
|
||||
|
||||
<!-- 一级栏目区块(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 th:switch="${block.homeStyle}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 线上展厅(cms_banner position=gallery;设计尺寸 800×520;每行列数由主题参数 galleryCols 控制) -->
|
||||
@@ -168,6 +97,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 置顶/头条区(cms_article topmost 优先) -->
|
||||
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<h2 class="section-title">头条推荐</h2>
|
||||
@@ -49,6 +59,7 @@
|
||||
</ul>
|
||||
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<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']}">更多 ></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>
|
||||
</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="头条与图片新闻">
|
||||
<div class="headline-grid">
|
||||
@@ -110,6 +120,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 置顶/头条区(cms_article topmost 优先) -->
|
||||
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<h2 class="section-title">头条推荐</h2>
|
||||
@@ -49,6 +59,7 @@
|
||||
</ul>
|
||||
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 置顶/头条区(cms_article topmost 优先) -->
|
||||
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<h2 class="section-title">头条推荐</h2>
|
||||
@@ -49,6 +59,7 @@
|
||||
</ul>
|
||||
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
|
||||
@@ -23,7 +23,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 头条区:头条推荐(置顶优先) + 图片新闻(有封面文章) -->
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<section class="sp-section sp-container" aria-label="头条与图片新闻">
|
||||
<div class="sp-headline">
|
||||
<div class="sp-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
@@ -116,6 +125,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 置顶/头条区(cms_article topmost 优先) -->
|
||||
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<h2 class="section-title">头条推荐</h2>
|
||||
@@ -49,6 +59,7 @@
|
||||
</ul>
|
||||
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user