feat(theme): S5 学院蓝主题改造——home-blocks/service/guide页+区块CSS+ThemeRegistry增强
build / build-push (push) Successful in 53s

This commit is contained in:
chenxing
2026-09-22 17:35:38 +08:00
parent 82d6ff4602
commit bc794ccce3
28 changed files with 1818 additions and 86 deletions
@@ -69,13 +69,14 @@ public class ThemeController {
} }
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema), /** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效 */ * 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效。
* 工作项 T:paramValues(包级参数值)两种来源统一写 .meta 覆盖层,null=不改 */
@PutMapping("/{name}/meta") @PutMapping("/{name}/meta")
public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept, public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept,
@PathVariable("name") String name, @PathVariable("name") String name,
@RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) { @RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) {
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name, ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
req.getTitle(), req.getDescription(), req.getParams()); req.getTitle(), req.getDescription(), req.getParams(), req.getParamValues());
return themeService.voOf(info); return themeService.voOf(info);
} }
@@ -23,6 +23,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern;
/** /**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用, * 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
@@ -288,6 +289,8 @@ public class PortalPageRenderer {
private static final int LAYOUT_SIZE_MAX = 20; private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8; private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6; private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/** 区块内手填链接:http(s) 绝对地址或站内根相对路径(与 SiteService 保存期校验同契约,拒绝伪协议) */
private static final Pattern LAYOUT_LINK_URL = Pattern.compile("^(https?://\\S+|/.+)$");
/** /**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。 * home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
@@ -341,6 +344,10 @@ public class PortalPageRenderer {
return buildGalleryBlock(site, spec, url); return buildGalleryBlock(site, spec, url);
case "links": case "links":
return buildLinksBlock(site, spec); return buildLinksBlock(site, spec);
case "quick_links":
return buildQuickLinksBlock(spec);
case "notice_bar":
return buildNoticeBarBlock(spec);
default: default:
return null; // 白名单外的脏数据跳过,不打挂渲染 return null; // 白名单外的脏数据跳过,不打挂渲染
} }
@@ -463,6 +470,48 @@ public class PortalPageRenderer {
return block; return block;
} }
/**
* 快捷入口宫格(v2 手册 §2.8 类型 A):数据是区块内联数组,不读任何外部表。
* 保存期已校验 url 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。
*/
private Map<String, Object> buildQuickLinksBlock(Map<String, Object> spec) {
List<Map<String, Object>> links = new ArrayList<>();
Object raw = spec.get("items");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String name = str(item.get("name"), "").trim();
String linkUrl = str(item.get("url"), "").trim();
if (name.isEmpty() || !LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
continue;
}
Map<String, Object> link = new HashMap<>();
link.put("name", name);
link.put("url", linkUrl);
links.add(link);
}
}
Map<String, Object> block = baseBlock(spec, "quick_links", "快捷通道");
block.put("quickLinks", links);
return block;
}
/** 公告条(v2 手册 §2.8 类型 B):站点单条手填通告;level 白名单外归一 info,url 不合法则退化为纯文本 */
private Map<String, Object> buildNoticeBarBlock(Map<String, Object> spec) {
String linkUrl = str(spec.get("url"), "").trim();
if (!LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
linkUrl = "";
}
Map<String, Object> block = baseBlock(spec, "notice_bar", "公告");
block.put("noticeText", str(spec.get("text"), "").trim());
block.put("noticeUrl", linkUrl);
block.put("noticeLevel", "warn".equals(str(spec.get("level"), "").trim()) ? "warn" : "info");
return block;
}
// ===== 布局装配工具 ===== // ===== 布局装配工具 =====
private static String str(Object v, String fallback) { private static String str(Object v, String fallback) {
@@ -44,9 +44,12 @@ public class SiteSaveRequest {
private String configJson; private String configJson;
/** /**
* 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key; * 主题微调参数(方案 §5.3)。
* 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。 *
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
*/ */
@Deprecated
private Map<String, String> themeParams; private Map<String, String> themeParams;
/** 0=停用 1=启用 */ /** 0=停用 1=启用 */
@@ -4,6 +4,7 @@ import com.yunhai.cms.theme.ThemeParam;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
import java.util.Map;
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */ /** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */
@Data @Data
@@ -16,4 +17,10 @@ public class ThemeMetaRequest {
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */ /** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */
private List<ThemeParam> params; private List<ThemeParam> params;
/**
* 包级参数值(工作项 T:参数微调迁皮肤包级):null=不改,非 null=整体替换(空 Map=恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃;内置包与外部包统一写 .meta 覆盖层。
*/
private Map<String, String> paramValues;
} }
@@ -5,6 +5,7 @@ import com.yunhai.cms.theme.ThemeParam;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 主题包 VO(管理端主题列表,方案 §5.2)。 * 主题包 VO(管理端主题列表,方案 §5.2)。
@@ -30,6 +31,12 @@ public class ThemeVo {
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */ /** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
private List<ThemeParamVo> params; private List<ThemeParamVo> params;
/**
* 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key;
* 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。
*/
private Map<String, String> paramValues;
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */ /** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
private String source; private String source;
@@ -82,6 +89,8 @@ public class ThemeVo {
for (ThemeParam p : info.getParams()) { for (ThemeParam p : info.getParams()) {
vo.getParams().add(ThemeParamVo.of(p)); vo.getParams().add(ThemeParamVo.of(p));
} }
vo.setParamValues(info.getParamValues() == null
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
return vo; return vo;
} }
} }
@@ -289,9 +289,12 @@ public class PortalQueryService {
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME; return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
} }
/** config_json.theme_params 原始值(非对象形态按无参数处理) */ /**
* 参数原始值(工作项 T:参数微调上移为皮肤包级):取当前主题的包级 {@code paramValues}
* ({@code themes/.meta/{name}.json} 覆盖层)。站点 {@code config_json.theme_params} 已退役、不再读取
* (存量残留由 SiteService 的 RESERVED 键容忍,不影响门户)。
*/
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) { private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params"); return themeRegistry.paramValuesOf(snapshot.getTheme());
return raw instanceof Map ? (Map<?, ?>) raw : null;
} }
} }
@@ -60,15 +60,25 @@ 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 同契约) */ /** home_layout(docs/16 §4.6 + v2 手册 §2.8)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES = private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList( java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
"article_list", "ticker", "gallery", "links"))); "article_list", "ticker", "gallery", "links", "quick_links", "notice_bar")));
private static final int HOME_LAYOUT_MAX_BLOCKS = 30; 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_BLOCK_KEYS = 32;
private static final int HOME_LAYOUT_MAX_TEXT = 100; 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_MIN = 1;
private static final int HOME_LAYOUT_SIZE_MAX = 20; private static final int HOME_LAYOUT_SIZE_MAX = 20;
/** quick_links(§2.8 类型 A):内联入口宫格上限与单项 name 长度 */
private static final int HOME_LAYOUT_MAX_QUICK_LINKS = 12;
private static final int HOME_LAYOUT_MAX_LINK_NAME = 20;
/** notice_bar(§2.8 类型 B):公告正文上限 */
private static final int HOME_LAYOUT_MAX_NOTICE_TEXT = 200;
/** 区块内手填链接:仅放行 http(s) 绝对地址或站内根相对路径(拒绝 javascript: 等伪协议) */
private static final Pattern HOME_LAYOUT_LINK_PATTERN = Pattern.compile("^(https?://\\S+|/.+)$");
/** notice_bar 提示级别白名单(缺省 info) */
private static final Set<String> HOME_LAYOUT_NOTICE_LEVELS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("info", "warn")));
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */ /** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240; private static final int CUSTOM_CSS_MAX_LEN = 10240;
@@ -174,7 +184,9 @@ public class SiteService {
} }
site.setTheme(resolvedTheme); site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson(), site.getId()); validateConfigJson(req.getConfigJson(), site.getId());
site.setConfigJson(mergeThemeParams(req, resolvedTheme)); // 工作项 T:参数微调上移为皮肤包级,站点不再持有参数值——themeParams 入参直接忽略
// (config_json 原样入库;theme_params 仍是 RESERVED 键以容忍存量/老前端提交,不报错)
site.setConfigJson(req.getConfigJson());
site.setStatus(req.getStatus() == null ? 1 : req.getStatus()); site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site); site = siteRepository.save(site);
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值) // B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
@@ -240,29 +252,6 @@ public class SiteService {
} }
} }
/**
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
* SPA 不再手拼 configJson 中的该节点。
*/
private String mergeThemeParams(SiteSaveRequest req, String theme) {
if (req.getThemeParams() == null) {
return req.getConfigJson();
}
Map<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
Map<String, String> filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams());
if (filtered.isEmpty()) {
config.remove("theme_params");
} else {
config.put("theme_params", filtered);
}
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
}
}
/** /**
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、 * 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。 * 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
@@ -381,7 +370,8 @@ public class SiteService {
/** /**
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键; * 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; * type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、字符串字段(title/subtitle)≤100;
* quick_links / notice_bar 另有分支校验(见 {@link #validateQuickLinksBlock} / {@link #validateNoticeBarBlock});
* categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。 * categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。 * 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
*/ */
@@ -411,6 +401,11 @@ public class SiteService {
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) { if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText())); throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText()));
} }
if ("quick_links".equals(type.asText())) {
validateQuickLinksBlock(block);
} else if ("notice_bar".equals(type.asText())) {
validateNoticeBarBlock(block);
}
JsonNode size = block.get("size"); JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
|| size.intValue() > HOME_LAYOUT_SIZE_MAX)) { || size.intValue() > HOME_LAYOUT_SIZE_MAX)) {
@@ -452,6 +447,63 @@ public class SiteService {
} }
} }
/** quick_links(v2 手册 §2.8 类型 A):items 必填、1~12 项,每项 {name ≤20 字, url 合法} */
private void validateQuickLinksBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links 区块必须包含 items(入口列表)");
}
if (!items.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 必须是数组");
}
if (items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 至少 1 项");
}
if (items.size() > HOME_LAYOUT_MAX_QUICK_LINKS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links items 不能超过 " + HOME_LAYOUT_MAX_QUICK_LINKS + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 每项必须是对象({name, url})");
}
JsonNode name = item.get("name");
if (name == null || !name.isTextual() || name.asText().trim().isEmpty()
|| name.asText().trim().length() > HOME_LAYOUT_MAX_LINK_NAME) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links 项 name 须为不超过 " + HOME_LAYOUT_MAX_LINK_NAME + " 字的文本");
}
requireLayoutLink(item.get("url"), "quick_links 项");
}
}
/** notice_bar(v2 手册 §2.8 类型 B):text 必填 ≤200 字;url 可选;level 可选且限 info/warn */
private void validateNoticeBarBlock(JsonNode block) {
JsonNode text = block.get("text");
if (text == null || !text.isTextual() || text.asText().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar 区块必须填写 text(公告正文)");
}
if (text.asText().length() > HOME_LAYOUT_MAX_NOTICE_TEXT) {
throw new BizException(ErrorCode.PARAM_ERROR,
"notice_bar text 不能超过 " + HOME_LAYOUT_MAX_NOTICE_TEXT + " 字");
}
if (block.has("url")) {
requireLayoutLink(block.get("url"), "notice_bar");
}
JsonNode level = block.get("level");
if (level != null && (!level.isTextual() || !HOME_LAYOUT_NOTICE_LEVELS.contains(level.asText()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar level 只能是 info 或 warn");
}
}
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url) */
private void requireLayoutLink(JsonNode url, String what) {
if (url == null || !url.isTextual() || !HOME_LAYOUT_LINK_PATTERN.matcher(url.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " url 须为 http(s):// 地址或站内路径(/ 开头)");
}
}
/** /**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝 * custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、 * {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
@@ -36,6 +36,13 @@ public class ThemeInfo {
private List<ThemeParam> params = new ArrayList<>(); private List<ThemeParam> params = new ArrayList<>();
/**
* 包级参数值(工作项 T:参数微调从站点级上移为皮肤包级)。
* 值不写 jar 内 theme.json,一律落 {@code {cms.theme.dir}/.meta/{name}.json} 覆盖层,
* 由 {@link ThemeRegistry#applyMetaOverride} 在装载时叠加;缺项回落 params[].defaultValue。
*/
private Map<String, String> paramValues = new LinkedHashMap<>();
public String screenshotPath() { public String screenshotPath() {
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot; return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
} }
@@ -24,6 +24,7 @@ import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
@@ -306,6 +307,7 @@ public class ThemePackageService {
root.put("description", description); root.put("description", description);
} }
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
copyMetaParamValues(sourceName, newName);
themeRegistry.reload(); themeRegistry.reload();
publishThemeChanged(); publishThemeChanged();
log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName); log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName);
@@ -318,6 +320,28 @@ public class ThemePackageService {
} }
} }
/**
* 派生时复制源包的 paramValues(v1 §3.4):参数值存 <code>.meta/{name}.json</code> 覆盖层、
* 不在包目录内,复制包体天然带不过去 → 不显式带则新包回落 theme.json 默认值,
* "复制包做差异化" 就得重调一遍参数。仅复制 paramValues 节点(title/description 由派生表单写入新包 theme.json)。
*/
private void copyMetaParamValues(String sourceName, String newName) throws IOException {
Path source = Paths.get(themeDir, ".meta", sourceName + ".json");
if (!Files.isRegularFile(source)) {
return;
}
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode values = mapper.readTree(source.toFile()).path("paramValues");
if (!values.isObject() || values.size() == 0) {
return;
}
Path target = Paths.get(themeDir, ".meta", newName + ".json");
Files.createDirectories(target.getParent());
com.fasterxml.jackson.databind.node.ObjectNode root = mapper.createObjectNode();
root.set("paramValues", values);
Files.write(target, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
/** 内置包(可能在 jar 内)复制:PathMatchingResourcePatternResolver 遍历其全部资源 */ /** 内置包(可能在 jar 内)复制:PathMatchingResourcePatternResolver 遍历其全部资源 */
private void copyClasspathPackage(String sourceName, Path target) throws IOException { private void copyClasspathPackage(String sourceName, Path target) throws IOException {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
@@ -354,9 +378,25 @@ public class ThemePackageService {
/** /**
* 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读, * 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读,
* 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。 * 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。
* 兼容签名:不改包级参数值。
*/ */
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description, public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> params) { List<ThemeParam> params) {
return updateMeta(user, name, title, description, params, null);
}
/**
* 元数据 + 包级参数值编辑(工作项 T:参数微调从站点级迁到皮肤包级)。
*
* <p>title/description/params 语义与任务四一致(内置包 schema 拒改);{@code paramValues} 则
* <b>内置包与外部包统一写 .meta 覆盖层</b>——值不属于包体内容,包体(theme.json + 模板 + 资产)
* 保持只读语义不变,「同包不同配色」的差异化仍由复制皮肤包承担。
*
* @param paramValues null=不改;非 null=整体替换(空 Map=清空、恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃(同批改 schema 时以新 schema 为准)
*/
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> params, Map<String, String> paramValues) {
requireSuperAdmin(user); requireSuperAdmin(user);
if (!themeRegistry.contains(name)) { if (!themeRegistry.contains(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name); throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
@@ -365,6 +405,15 @@ public class ThemePackageService {
if (!external && params != null) { if (!external && params != null) {
throw new BizException(ErrorCode.PARAM_ERROR, "内置包参数 schema 不可修改(仅显示名称/描述可改)"); throw new BizException(ErrorCode.PARAM_ERROR, "内置包参数 schema 不可修改(仅显示名称/描述可改)");
} }
Map<String, String> filteredValues = null;
if (paramValues != null) {
ThemeInfo schema = themeRegistry.get(name);
if (external && params != null) {
schema = new ThemeInfo();
schema.setParams(params);
}
filteredValues = schema.filterValidValues(paramValues);
}
try { try {
if (external) { if (external) {
Path json = Paths.get(themeDir, name, "theme.json"); Path json = Paths.get(themeDir, name, "theme.json");
@@ -388,8 +437,11 @@ public class ThemePackageService {
root.set("params", mapper.valueToTree(params)); root.set("params", mapper.valueToTree(params));
} }
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
if (filteredValues != null) {
writeMetaOverride(name, null, null, filteredValues);
}
} else { } else {
writeBuiltinMetaOverride(name, title, description); writeMetaOverride(name, title, description, filteredValues);
} }
themeRegistry.reload(); themeRegistry.reload();
publishThemeChanged(); publishThemeChanged();
@@ -402,12 +454,16 @@ public class ThemePackageService {
} }
/** /**
* 内置包元数据覆盖层写入(任务四):title/description 全空 = 删除覆盖层(恢复默认), * .meta 覆盖层写入:title/description/paramValues 合并进 {themeDir}/.meta/{name}.json。
* 否则合并写入 {themeDir}/.meta/{name}.json(title 空且 description 非空时保留已有 title)。 * 三者全空且未提交 paramValues(即任务四的"恢复默认"手势)→ 删除覆盖层;
* 显式提交空 paramValues 只摘掉 paramValues 节点,保留覆盖层里既有的 title/description。
*/ */
private void writeBuiltinMetaOverride(String name, String title, String description) throws IOException { private void writeMetaOverride(String name, String title, String description,
Map<String, String> paramValues) throws IOException {
Path overlay = Paths.get(themeDir, ".meta", name + ".json"); Path overlay = Paths.get(themeDir, ".meta", name + ".json");
if (!StringUtils.hasText(title) && (description == null || description.trim().isEmpty())) { boolean noTitle = !StringUtils.hasText(title);
boolean noDesc = description == null || description.trim().isEmpty();
if (noTitle && noDesc && paramValues == null) {
try { try {
Files.deleteIfExists(overlay); Files.deleteIfExists(overlay);
} catch (IOException e) { } catch (IOException e) {
@@ -433,6 +489,13 @@ public class ThemePackageService {
if (description != null) { if (description != null) {
root.put("description", description); root.put("description", description);
} }
if (paramValues != null) {
if (paramValues.isEmpty()) {
root.remove("paramValues");
} else {
root.set("paramValues", mapper.valueToTree(paramValues));
}
}
Files.createDirectories(overlay.getParent()); Files.createDirectories(overlay.getParent());
Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
} }
@@ -1,22 +1,40 @@
package com.yunhai.cms.theme; package com.yunhai.cms.theme;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用, * 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用,
* 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移: * 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移:
* specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、 * specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、
* fragments/head.html、param 规范、screenshot 存在。 * fragments/head.html、首页区块引用一致性(§2.5 坑⑤)、param 规范、screenshot 存在。
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1)。 * existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1);
* readFn 读资源文本(UTF-8),读不到返回 null(不臆断,交由存在性校验与模板引擎兜底)。
*/ */
public final class ThemePackageValidator { public final class ThemePackageValidator {
/** 本包首页区块模板(自研形态承载体) */
private static final String HOME_BLOCKS = "fragments/home-blocks.html";
/** Thymeleaf 模板引用对:~{<路径表达式> :: <fragment>( */
private static final Pattern TEMPLATE_REF =
Pattern.compile("~\\{([\\s\\S]*?)\\s*::\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\(");
/** fragment 定义:th:fragment="name(...)"(空格/逗号分隔的多签名一律取标识符) */
private static final Pattern FRAGMENT_DEF = Pattern.compile("th:fragment\\s*=\\s*\"([^\"]*)\"");
private ThemePackageValidator() { private ThemePackageValidator() {
} }
/** 校验不通过抛 IllegalArgumentException(bad package 语义,Registry 剔除 + log.warn) */ /** 校验不通过抛 IllegalArgumentException(bad package 语义,Registry 剔除 + log.warn) */
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn) { public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn,
Function<String, String> readFn) {
if (info == null) { if (info == null) {
throw new IllegalArgumentException("清单为空"); throw new IllegalArgumentException("清单为空");
} }
@@ -38,6 +56,7 @@ public final class ThemePackageValidator {
if (!existsFn.test("fragments/head.html")) { if (!existsFn.test("fragments/head.html")) {
throw new IllegalArgumentException("缺 fragments/head.html 公共片段"); throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
} }
validateHomeBlockFragmentRefs(dirName, readFn);
validateParams(info); validateParams(info);
String shot = info.screenshotPath(); String shot = info.screenshotPath();
if (!existsFn.test(shot)) { if (!existsFn.test(shot)) {
@@ -72,4 +91,75 @@ public final class ThemePackageValidator {
} }
} }
} }
/**
* 首页区块门禁(v2 手册 §2.5 坑⑤):home-blocks **不是**包的必需项——B 档包整段走
* themes/common 兜底骨架属正常形态,强制"8 形态齐全"会把它们剔除并静默回落 default(重大事故)。
* 只校验**引用一致性**:home.html 每把一种形态分发给本包 fragments/home-blocks
* (${themeBase} 拼接或 themes/&lt;目录名&gt;/ 字面两种写法),该文件就必须存在并定义被引用的
* fragment,否则门户首页渲染整页 500。缺项清单写进异常消息,由 Registry 剔除分支 log.warn 带出。
*/
static void validateHomeBlockFragmentRefs(String dirName, Function<String, String> readFn) {
String home = readFn.apply("home.html");
if (home == null || home.isEmpty()) {
return;
}
List<String> referenced = ownHomeBlockRefs(home, dirName);
if (referenced.isEmpty()) {
return;
}
String blocks = readFn.apply(HOME_BLOCKS);
if (blocks == null) {
throw new IllegalArgumentException("home.html 把区块分发给本包 " + HOME_BLOCKS
+ ",但该文件不存在(引用形态:" + join(referenced) + ")");
}
Set<String> defined = fragmentNamesOf(blocks);
List<String> missing = new ArrayList<>();
for (String name : referenced) {
if (!defined.contains(name)) {
missing.add(name);
}
}
if (!missing.isEmpty()) {
throw new IllegalArgumentException("本包 " + HOME_BLOCKS + " 缺 fragment 定义:" + join(missing)
+ "(home.html 已引用这些形态,门户首页渲染会整页 500)");
}
}
/** home.html 中指向**本包** home-blocks 的 fragment 名(按出现顺序去重;common 兜底引用不计入) */
static List<String> ownHomeBlockRefs(String homeHtml, String dirName) {
List<String> names = new ArrayList<>();
Matcher matcher = TEMPLATE_REF.matcher(homeHtml);
while (matcher.find()) {
String target = matcher.group(1);
if (!target.contains("/fragments/home-blocks") || target.contains("themes/common/")) {
continue;
}
boolean ownPackage = target.contains("themeBase") || target.contains("themes/" + dirName + "/");
String fragment = matcher.group(2);
if (ownPackage && !names.contains(fragment)) {
names.add(fragment);
}
}
return names;
}
/** 模板文本内声明的全部 fragment 名 */
static Set<String> fragmentNamesOf(String templateText) {
Set<String> names = new HashSet<>();
Matcher matcher = FRAGMENT_DEF.matcher(templateText);
while (matcher.find()) {
for (String signature : matcher.group(1).trim().split("[\\s,]+")) {
String name = signature.split("\\(")[0].trim();
if (!name.isEmpty()) {
names.add(name);
}
}
}
return names;
}
private static String join(List<String> names) {
return String.join("、", names);
}
} }
@@ -9,9 +9,12 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -113,8 +116,9 @@ public class ThemeRegistry {
String dirName = dirNameOf(resource); String dirName = dirNameOf(resource);
try { try {
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class); ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName, rel -> ThemePackageValidator.validate(info, dirName,
new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists()); rel -> new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists(),
rel -> readClasspath("templates/themes/" + dirName + "/" + rel));
applyMetaOverride(info); applyMetaOverride(info);
loaded.put(info.getName(), info); loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_BUILTIN); loadedSources.put(info.getName(), SOURCE_BUILTIN);
@@ -130,7 +134,9 @@ public class ThemeRegistry {
Path json = pkg.resolve("theme.json"); Path json = pkg.resolve("theme.json");
try { try {
ThemeInfo info = MAPPER.readValue(json.toFile(), ThemeInfo.class); ThemeInfo info = MAPPER.readValue(json.toFile(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName, rel -> Files.exists(pkg.resolve(rel))); ThemePackageValidator.validate(info, dirName,
rel -> Files.exists(pkg.resolve(rel)),
rel -> readTextFile(pkg.resolve(rel)));
if (loaded.containsKey(info.getName())) { if (loaded.containsKey(info.getName())) {
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName); log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
continue; continue;
@@ -222,6 +228,24 @@ public class ThemeRegistry {
return REQUIRED_TEMPLATES; return REQUIRED_TEMPLATES;
} }
/** 读 classpath 包内文本(UTF-8);读不到返回 null(区块门禁据此跳过,不臆断) */
private static String readClasspath(String location) {
try (InputStream in = new ClassPathResource(location).getInputStream()) {
return StreamUtils.copyToString(in, StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
/** 读外部包内文本(UTF-8);读不到返回 null */
private static String readTextFile(Path file) {
try {
return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
// ===== 资源定位 ===== // ===== 资源定位 =====
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */ /** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
@@ -235,8 +259,8 @@ public class ThemeRegistry {
} }
/** /**
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description—— * 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
* 内置包 theme.json 在 jar 内只读,显示名/描述改名走此通道;损坏的覆盖层忽略(按原始元数据注册)。 * 内置包 theme.json 在 jar 内只读,显示名/描述改名与包级参数值均走此通道;损坏的覆盖层忽略(按原始元数据注册)。
*/ */
private void applyMetaOverride(ThemeInfo info) { private void applyMetaOverride(ThemeInfo info) {
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) { if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
@@ -254,11 +278,26 @@ public class ThemeRegistry {
if (meta.getDescription() != null) { if (meta.getDescription() != null) {
info.setDescription(meta.getDescription()); info.setDescription(meta.getDescription());
} }
if (meta.getParamValues() != null && !meta.getParamValues().isEmpty()) {
info.setParamValues(new LinkedHashMap<>(meta.getParamValues()));
}
} catch (Exception e) { } catch (Exception e) {
log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage()); log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage());
} }
} }
/**
* 包级参数值(工作项 T):主题不存在返回空 Map(调用方免判空)。
* 返回不可变视图——参数值的唯一写入口是 {@code ThemePackageService.updateMeta}。
*/
public Map<String, String> paramValuesOf(String name) {
ThemeInfo info = name == null ? null : themes.get(name);
if (info == null || info.getParamValues() == null) {
return java.util.Collections.emptyMap();
}
return java.util.Collections.unmodifiableMap(info.getParamValues());
}
/** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */ /** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */
protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>(); protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
if (themeDir == null || themeDir.isEmpty()) { if (themeDir == null || themeDir.isEmpty()) {
@@ -4,7 +4,7 @@
样式随 fragment 内联携带——themes/common 不注册为皮肤包(无 theme.json),主题资产通道不服务该目录; 样式随 fragment 内联携带——themes/common 不注册为皮肤包(无 theme.json),主题资产通道不服务该目录;
颜色回落站点主题变量(--primary 等 themeVarsCss 注入),无变量时用内置缺省。 颜色回落站点主题变量(--primary 等 themeVarsCss 注入),无变量时用内置缺省。
契约:block 为渲染端组装的同构 Map——type/title/category.name/homeStyle/items/moreUrl/subtitle/ 契约:block 为渲染端组装的同构 Map——type/title/category.name/homeStyle/items/moreUrl/subtitle/
background/source/linkGroups,与回落模式 categoryBlocks 的块字段兼容。 background/source/linkGroups/quickLinks/noticeText/noticeUrl/noticeLevel,与回落模式 categoryBlocks 的块字段兼容。
--> -->
<th:block th:fragment="style"> <th:block th:fragment="style">
<style> <style>
@@ -19,6 +19,10 @@
--plb-band-bg: var(--bg, #f5f7fb); --plb-band-bg: var(--bg, #f5f7fb);
--plb-border: var(--border, rgba(0, 0, 0, .08)); --plb-border: var(--border, rgba(0, 0, 0, .08));
--plb-radius: var(--radius, 10px); --plb-radius: var(--radius, 10px);
/* 公告条 warn 档:皮肤契约没有告警色变量,取骨架自持的暖色(不跟随皮肤) */
--plb-warn-fg: #d46b08;
--plb-warn-bg: #fff7e6;
--plb-warn-border: #ffd591;
} }
.plb-section{padding:26px 16px} .plb-section{padding:26px 16px}
.plb-container{max-width:1200px;margin:0 auto} .plb-container{max-width:1200px;margin:0 auto}
@@ -67,15 +71,28 @@
.plb-links{display:flex;flex-wrap:wrap;gap:10px;list-style:none;margin:0;padding:0} .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:var(--plb-muted);text-decoration:none;border:1px solid var(--plb-border);border-radius:16px;padding:4px 14px;background:var(--plb-card-bg)} .plb-links a{display:inline-block;font-size:13px;color:var(--plb-muted);text-decoration:none;border:1px solid var(--plb-border);border-radius:16px;padding:4px 14px;background:var(--plb-card-bg)}
.plb-links a:hover{color:var(--plb-primary);border-color:var(--plb-primary)} .plb-links a:hover{color:var(--plb-primary);border-color:var(--plb-primary)}
.plb-quick-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;list-style:none;margin:0;padding:0}
.plb-quick-link{display:flex;align-items:center;justify-content:center;min-height:62px;padding:10px 12px;font-size:14px;line-height:1.4;text-align:center;color:var(--plb-text);text-decoration:none;background:var(--plb-page-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius);overflow-wrap:anywhere}
.plb-quick-link:hover{color:var(--plb-primary);border-color:var(--plb-primary)}
.plb-notice-bar{background:var(--plb-band-bg);border-top:1px solid var(--plb-border);border-bottom:1px solid var(--plb-border);padding:10px 16px;font-size:14px}
.plb-notice-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:12px}
.plb-notice-tag{flex:none;font-weight:600;color:var(--plb-primary)}
/* 中文可按字断行,flex 项不锁 min-width:0 就会被长公告挤破容器 */
.plb-notice-link,.plb-notice-text{flex:1;min-width:0;color:var(--plb-text);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.plb-notice-link:hover{text-decoration:underline;color:var(--plb-primary)}
.plb-notice-warn{background:var(--plb-warn-bg);border-color:var(--plb-warn-border)}
.plb-notice-warn .plb-notice-tag{color:var(--plb-warn-fg)}
/* 全宽区块必须跨满整行:网格型主题(mint-breeze 系 .home-main 是三列)会把它们塞进单列, /* 全宽区块必须跨满整行:网格型主题(mint-breeze 系 .home-main 是三列)会把它们塞进单列,
非网格父容器下 grid-column 无副作用,故由骨架自带。 */ 非网格父容器下 grid-column 无副作用,故由骨架自带。 */
main > .plb-ticker{grid-column:1 / -1} main > .plb-ticker{grid-column:1 / -1}
main > .plb-newshot{grid-column:1 / -1} main > .plb-newshot{grid-column:1 / -1}
main > .plb-section{grid-column:1 / -1} main > .plb-section{grid-column:1 / -1}
main > .plb-notice-bar{grid-column:1 / -1}
@media (max-width:768px){ @media (max-width:768px){
.plb-grid2{columns:1} .plb-grid2{columns:1}
.plb-imagewall{grid-template-columns:repeat(2,1fr)} .plb-imagewall{grid-template-columns:repeat(2,1fr)}
.plb-gallery-grid{grid-template-columns:repeat(2,1fr)} .plb-gallery-grid{grid-template-columns:repeat(2,1fr)}
.plb-quick-grid{grid-template-columns:repeat(3,1fr)}
.plb-feat img{width:120px;height:80px} .plb-feat img{width:120px;height:80px}
.plb-ticker-item{max-width:150px} .plb-ticker-item{max-width:150px}
} }
@@ -99,6 +116,8 @@ main > .plb-section{grid-column:1 / -1}
<th:block th:case="'ticker'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbTicker(${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="'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="'links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbLinks(${block})}" /></th:block>
<th:block th:case="'quick_links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbQuickLinks(${block})}" /></th:block>
<th:block th:case="'notice_bar'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbNoticeBar(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCard(${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:block>
@@ -274,3 +293,36 @@ main > .plb-section{grid-column:1 / -1}
</div> </div>
</div> </div>
</section> </section>
<!-- 区块:快捷入口宫格(quick_links,v2 手册 §2.8 类型 A):区块内联数组,不读友情链接表;
非法 url 项已由渲染端丢弃,这里只消费 block['quickLinks']=[{name,url}] -->
<section class="plb-section plb-quick-links" th:fragment="plbQuickLinks(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-quick-grid" th:if="${!#lists.isEmpty(block['quickLinks'])}">
<li th:each="l : ${block['quickLinks']}">
<a class="plb-quick-link" th:href="${l.url}" th:text="${l.name}">入口名</a>
</li>
</ul>
<!-- 空态守卫必须包在 th:block 外层(坑①:th:replace 优先级 100 先于 th:if 300 求值) -->
<th:block th:if="${#lists.isEmpty(block['quickLinks'])}">
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</div>
</section>
<!-- 区块:公告条(notice_bar,v2 手册 §2.8 类型 B):单条手填通告,非滚动非文章列表;
level 缺省 info,warn 走告警配色;url 非法时渲染端置空 → 退化为纯文本 -->
<div class="plb-notice-bar" th:fragment="plbNoticeBar(block)" role="note"
th:if="${!#strings.isEmpty(block['noticeText'])}"
th:classappend="${block['noticeLevel'] == 'warn' ? 'plb-notice-warn' : 'plb-notice-info'}">
<div class="plb-notice-inner">
<span class="plb-notice-tag" th:text="${block['title']}">公告</span>
<a class="plb-notice-link" th:if="${!#strings.isEmpty(block['noticeUrl'])}"
th:href="${block['noticeUrl']}" th:text="${block['noticeText']}">公告正文</a>
<span class="plb-notice-text" th:unless="${!#strings.isEmpty(block['noticeUrl'])}"
th:text="${block['noticeText']}">公告正文</span>
</div>
</div>
@@ -1371,3 +1371,395 @@ input, button, select, textarea { font-size: inherit; color: inherit; }
.sp-side-nav { display: none; } .sp-side-nav { display: none; }
.sp-cols { display: block; } .sp-cols { display: block; }
} }
/* ---------------------------------------------------------------------
16. 首页布局模式区块(fragments/home-blocks.html · 手册 §A.2)
站点配了 home_layout 时首页正文由本包 fragment 渲染(不再落 themes/common 骨架)。
只补布局模式才出现的排布件(双栏要闻、速递条、图墙、链接胶囊、快捷宫格、公告条);
标题、白卡、日期方块、置顶徽标、空态沿用第 7/9 节既有语汇。
颜色一律走 --sp-* / 契约别名变量(换肤必须穿透到这里)。
--------------------------------------------------------------------- */
/* 坑③:自研 fragment 后 common 骨架不再兜 grid-column,区块根类必须自跨整行
(本包 main 目前非 grid 父容器,该声明无副作用;皮肤改成网格布局时不至于塌列) */
main > .sp-section,
main > .sp-ticker,
main > .sp-notice-bar { grid-column: 1 / -1; }
.sp-section { position: relative; }
.sp-sec-bg {
position: absolute; inset: 0; z-index: 0;
background-size: cover; background-position: center;
opacity: .45; pointer-events: none;
}
/* 定位元素默认画在普通流之上:不抬 z-index,新闻背景带会糊在卡片正面(academic-navy 实测整卡泛蓝) */
.sp-sec-bg + .sp-block { position: relative; z-index: 1; background: var(--sp-card-bg); }
/* 整宽区块卡:不参与第 9 节三列网格,故不抬起 hover 位移 */
.sp-block--flow { padding: 20px 26px 12px; }
.sp-block--flow:hover { transform: none; box-shadow: var(--sp-shadow-sm); }
/* 双栏要闻:grid 轨道必须 minmax(0,1fr),否则 nowrap 省略行的 min-content 会撑破轨道(中文尤其明显) */
.sp-split {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 26px;
padding-top: 14px;
}
.sp-split > * { min-width: 0; }
/* 左图限高 + 右列行均分:否则 16/9 大图把左栏撑到 400px,右列 5 行挤在顶部留大片空白 */
.sp-split .sp-feat-cover { max-height: 300px; }
.sp-split .sp-block-list { display: flex; flex-direction: column; justify-content: space-between; }
.sp-split .sp-block-item { padding: 12px 0; }
.sp-block-list--two { columns: 2; column-gap: 34px; }
.sp-block-list--two .sp-block-item { break-inside: avoid; }
.sp-feat-cover--row { aspect-ratio: 16 / 7; }
.sp-item-date {
flex: none;
font-size: 12px;
color: var(--sp-text-light);
font-variant-numeric: tabular-nums;
}
/* 来源徽标:细描边小 tag,跟随主色(媒体聚焦形态与 news_grid/card 元信息共用) */
.sp-src {
flex: none;
font-size: 11px; line-height: 18px; padding: 0 6px;
color: var(--sp-primary);
border: 1px solid var(--sp-primary);
border-radius: 2px;
}
/* 图片墙 / 线上展厅:四列格 + 底部渐变题注(渐变端点用主色深变量,不写死色值) */
.sp-wall {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin: 16px 0 0;
}
.sp-wall-item {
position: relative;
border-radius: var(--sp-radius);
overflow: hidden;
box-shadow: var(--sp-shadow-sm);
}
.sp-wall-item img { width: 100%; height: 158px; object-fit: cover; transition: transform .5s ease; }
.sp-wall-item:hover img { transform: scale(1.05); }
.sp-wall-cap {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 26px 10px 9px;
font-size: 13px;
color: var(--sp-ink-on-dark);
background: linear-gradient(transparent, var(--sp-primary-dark));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 线上展厅:整幅居中(本包签名式双线标题带),去掉紧贴区块上沿的那道顶边线 */
.sp-gallery .sp-block-head { border-top: 0; padding-top: 0; }
.sp-gallery-sub {
margin: -4px 0 4px;
text-align: center;
font-size: 12px; letter-spacing: .18em; text-transform: uppercase;
color: var(--sp-text-light);
}
/* 要闻速递横条:flex 项自动最小尺寸 = min-content,中文可按字断行 → 不锁 nowrap / 不让出收缩权就塌成一字一列 */
.sp-ticker {
position: relative;
background: var(--sp-primary);
color: var(--sp-ink-on-dark);
padding: 11px 0;
font-size: 14px;
}
.sp-ticker-inner { display: flex; align-items: center; gap: 16px; min-width: 0; }
.sp-ticker-label {
flex: none;
font-weight: 700; letter-spacing: .06em;
padding-right: 14px;
border-right: 1px solid var(--sp-ink-on-dark-faint);
}
.sp-ticker-list {
display: flex; gap: 28px;
flex: 1; min-width: 0;
overflow: hidden; white-space: nowrap;
}
.sp-ticker-list li { flex: none; min-width: 0; }
.sp-ticker-item {
display: block; flex: none;
max-width: 320px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--sp-ink-on-dark-soft);
}
.sp-ticker-item:hover { color: var(--sp-ink-on-dark); text-decoration: underline; }
/* 友情链接胶囊行 */
.sp-links-group {
margin: 12px 0 0;
font-size: 13px; letter-spacing: .08em;
color: var(--sp-text-light);
}
.sp-links { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 0; }
.sp-links a {
font-size: 13px;
color: var(--sp-text-light);
padding: 4px 14px;
background: var(--sp-card-bg);
border: 1px solid var(--sp-border);
border-radius: 999px;
}
.sp-links a:hover { color: var(--sp-primary); border-color: var(--sp-primary); }
/* 快捷通道(quick_links):六列入口格,主蓝细边、hover 反色;名称超长省略不撑格 */
.sp-quick-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin: 16px 0 6px;
}
.sp-quick-link {
display: block;
padding: 14px 10px;
text-align: center;
font-size: 14px;
color: var(--sp-primary-dark);
background: var(--sp-bg);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
transition: color var(--sp-speed) ease, background var(--sp-speed) ease, border-color var(--sp-speed) ease;
}
.sp-quick-link:hover {
color: var(--sp-ink-on-dark);
background: var(--sp-primary);
border-color: var(--sp-primary);
}
/* 公告条(notice_bar):单条整宽提示。warn 档是语义色、不属于皮肤可配调色板,
故与 themes/common 骨架同一处置——自持三个 --sp-warn-* 令牌,不假装皮肤契约有告警变量 */
.sp-notice-bar {
--sp-warn-fg: #d46b08;
--sp-warn-bg: #fff7e6;
--sp-warn-border: #ffd591;
position: relative;
padding: 12px 0;
font-size: 14px;
background: var(--sp-card-bg);
border-top: 1px solid var(--sp-border);
border-bottom: 1px solid var(--sp-border);
}
.sp-notice-inner { display: flex; align-items: baseline; gap: 14px; min-width: 0; }
.sp-notice-tag {
flex: none;
font-weight: 700; letter-spacing: .06em;
color: var(--sp-primary);
}
.sp-notice-link,
.sp-notice-text {
flex: 1; min-width: 0;
color: var(--sp-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sp-notice-link:hover { color: var(--sp-primary); text-decoration: underline; text-underline-offset: 3px; }
.sp-notice--info { border-top-color: var(--sp-primary); border-bottom-color: var(--sp-primary); }
.sp-notice--warn {
background: var(--sp-warn-bg);
border-top-color: var(--sp-warn-border);
border-bottom-color: var(--sp-warn-border);
}
.sp-notice--warn .sp-notice-tag { color: var(--sp-warn-fg); }
.sp-notice--warn .sp-notice-link { color: var(--sp-warn-fg); }
@media (max-width: 992px) {
.sp-split { grid-template-columns: minmax(0, 1fr); }
.sp-wall { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sp-block-list--two { columns: 1; }
.sp-ticker-item { max-width: 180px; }
.sp-block--flow { padding: 16px 16px 8px; }
.sp-quick-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.sp-notice-inner { flex-direction: column; gap: 4px; }
}
@media (max-width: 768px) {
.sp-quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ---------------------------------------------------------------------
17. 服务中心 / 事项指南(service.html · guide.html,svc 页族)
页面卡/面包屑/页题/分页沿用第 10 节既有语汇,这里只补事项特有的件。
--------------------------------------------------------------------- */
.sp-svc-page { min-width: 0; }
.sp-svc-filters {
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius);
padding: 10px 14px;
margin-bottom: 18px;
}
.sp-svc-filter-group { display: flex; align-items: flex-start; gap: 12px; padding: 6px 0; }
.sp-svc-filter-group + .sp-svc-filter-group { border-top: 1px dashed var(--sp-border); }
.sp-svc-filter-label { flex: none; font-size: 13px; font-weight: 600; color: var(--sp-text); padding-top: 3px; }
.sp-svc-filter-options { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; }
.sp-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px;
font-size: 13px;
color: var(--sp-text);
background: var(--sp-card-bg);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius);
transition: color var(--sp-speed) ease, border-color var(--sp-speed) ease, background var(--sp-speed) ease;
}
.sp-chip em { font-style: normal; font-size: 12px; color: var(--sp-text-light); }
.sp-chip:hover { color: var(--sp-primary); border-color: var(--sp-primary); }
.sp-chip.active { background: var(--sp-primary); border-color: var(--sp-primary); color: var(--sp-ink-on-dark); }
.sp-chip.active em { color: var(--sp-ink-on-dark-soft); }
.sp-svc-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.sp-svc-search {
display: flex;
width: min(420px, 100%);
border: 1px solid var(--sp-border);
border-radius: var(--sp-radius);
overflow: hidden;
transition: border-color var(--sp-speed) ease, box-shadow var(--sp-speed) ease;
}
.sp-svc-search:focus-within { border-color: var(--sp-primary); box-shadow: var(--sp-shadow-sm); }
.sp-svc-search input { flex: 1; min-width: 0; border: 0; padding: 8px 12px; font-size: 14px; }
.sp-svc-search input:focus { outline: none; }
.sp-svc-search button {
border: 0;
background: var(--sp-primary);
color: var(--sp-ink-on-dark);
font-size: 14px;
padding: 8px 22px;
transition: background var(--sp-speed) ease;
}
.sp-svc-search button:hover { background: var(--sp-primary-light); }
.sp-svc-clear { font-size: 13px; color: var(--sp-text-light); text-decoration: underline; text-underline-offset: 3px; }
.sp-svc-clear:hover { color: var(--sp-primary); }
/* 事项卡列表 */
.sp-svc-list > li + li { border-top: 1px solid var(--sp-border); }
.sp-svc-item { display: flex; gap: 18px; padding: 16px 0; min-width: 0; }
.sp-svc-item-cover {
flex: none; width: 120px; aspect-ratio: 16 / 9;
border-radius: var(--sp-radius);
overflow: hidden;
background: var(--sp-bg);
}
.sp-svc-item-cover img { width: 100%; height: 100%; object-fit: cover; }
.sp-svc-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.sp-svc-item-title { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; }
.sp-svc-item-name { font-size: 16px; font-weight: 600; color: var(--sp-text); transition: color var(--sp-speed) ease; }
.sp-svc-item:hover .sp-svc-item-name { color: var(--sp-primary); }
/* 事项徽标:本包调色板没有第二语义色,线上办理态改用主色深 + 描边区分(不新增裸色) */
.sp-tag {
flex: none;
font-size: 12px; line-height: 18px; padding: 0 6px;
color: var(--sp-primary);
background: var(--sp-bg);
border-radius: 2px;
}
.sp-tag.online { color: var(--sp-primary-dark); border: 1px solid var(--sp-primary-light); }
.sp-svc-item-summary {
font-size: 13px; color: var(--sp-text-light); line-height: 1.7;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.sp-svc-item-meta { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--sp-text-light); }
/* 事项指南页 */
.sp-svc-guide,
.sp-svc-guide-body,
.sp-svc-unavailable { min-width: 0; }
.sp-svc-apply { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.sp-svc-apply-btn {
display: inline-flex; align-items: center;
padding: 8px 28px;
font-size: 16px; font-weight: 600;
color: var(--sp-ink-on-dark);
background: var(--sp-primary);
border-radius: var(--sp-radius);
box-shadow: var(--sp-shadow-sm);
transition: background var(--sp-speed) ease, box-shadow var(--sp-speed) ease, transform var(--sp-speed) ease;
}
.sp-svc-apply-btn:hover {
color: var(--sp-ink-on-dark);
background: var(--sp-primary-dark);
box-shadow: var(--sp-shadow-md);
transform: translateY(-1px);
}
.sp-svc-apply-hint { font-size: 13px; color: var(--sp-text-light); }
.sp-svc-summary {
margin-bottom: 18px;
padding: 10px 16px;
font-size: 13px; color: var(--sp-text-light); line-height: 1.7;
background: var(--sp-bg);
border-left: 4px solid var(--sp-primary-light);
border-radius: 0 var(--sp-radius) var(--sp-radius) 0;
}
.sp-svc-info { width: 100%; border-collapse: collapse; margin: 12px 0 24px; font-size: 13px; }
.sp-svc-info th,
.sp-svc-info td {
border: 1px solid var(--sp-border);
padding: 8px 12px;
text-align: left; vertical-align: top;
}
.sp-svc-info th { width: 120px; background: var(--sp-bg); color: var(--sp-text); font-weight: 600; white-space: nowrap; }
.sp-svc-section { margin-top: 24px; }
.sp-svc-section h2 {
margin-bottom: 12px;
font-size: 18px; line-height: 1.5;
color: var(--sp-text);
padding-left: 12px;
border-left: 4px solid var(--sp-primary);
}
.sp-svc-attachments ul { display: flex; flex-direction: column; gap: 8px; }
.sp-svc-attachments a { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--sp-primary); }
.sp-svc-attachments a:hover { text-decoration: underline; text-underline-offset: 3px; }
.sp-svc-att-size { font-size: 12px; color: var(--sp-text-light); margin-left: 8px; }
.sp-svc-links {
display: flex; flex-wrap: wrap; gap: 12px;
margin-top: 24px; padding-top: 16px;
border-top: 1px dashed var(--sp-border);
}
.sp-svc-links a {
font-size: 13px;
color: var(--sp-primary);
padding: 4px 16px;
border: 1px solid var(--sp-primary);
border-radius: var(--sp-radius);
transition: background var(--sp-speed) ease, color var(--sp-speed) ease;
}
.sp-svc-links a:hover { background: var(--sp-primary); color: var(--sp-ink-on-dark); }
.sp-svc-related { margin-top: 32px; }
.sp-svc-related-title {
margin-bottom: 12px;
font-size: 18px; line-height: 1.5;
padding-left: 12px;
border-left: 4px solid var(--sp-primary);
}
.sp-svc-related-list > li + li { border-top: 1px dashed var(--sp-border); }
.sp-svc-related-list a {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 0; min-width: 0;
}
.sp-svc-related-name {
flex: 1; min-width: 0;
font-size: 14px; color: var(--sp-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sp-svc-related-list a:hover .sp-svc-related-name { color: var(--sp-primary); }
.sp-svc-related-meta {
flex: none; display: inline-flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--sp-text-light);
}
/* ---------------------------------------------------------------------
18. headerStyle=plain 变体(theme.json select 参数,header 根 class 切换)
banner(缺省)= 现形态;plain = 收起深蓝顶栏、压低品牌行——简洁顶栏。
--------------------------------------------------------------------- */
.sp-header--plain .sp-topbar { display: none; }
.sp-header--plain .sp-brandbar-inner { padding-top: 10px; padding-bottom: 10px; }
.sp-header--plain .sp-brand-badge { width: 44px; height: 44px; }
.sp-header--plain .sp-site-name { font-size: 22px; }
@@ -1,8 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<!-- scholar-blue 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、 <!-- scholar-blue 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 --> 主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。
<header class="sp-header" th:fragment="header"> headerStyle=plain(theme.json select 参数)时收起深蓝顶栏、压低品牌行——root class 切换,CSS 第 18 节消费。 -->
<header class="sp-header" th:fragment="header"
th:classappend="${themeParams['headerStyle'] == 'plain'} ? 'sp-header--plain'">
<a class="skip-link" href="#main-content">跳到主要内容</a> <a class="skip-link" href="#main-content">跳到主要内容</a>
<!-- 顶栏 --> <!-- 顶栏 -->
@@ -0,0 +1,243 @@
<!--
scholar-blue(学院蓝)首页区块 fragment(v2 手册 §A.2 · P-A1 定型模式):
站点配了 home_layout 时,区块正文必须由本文件渲染,不能落到 themes/common 的通用骨架——
否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因,见手册 §2.2)。
结构复用本包 main.css 第 7/9 节既有语汇(sp-block / sp-block-head--bar / sp-block-list /
sp-block-item / sp-block-item-title / sp-date-chip / sp-feat-* / sp-top-badge / sp-empty),
布局模式特有的排布(双栏要闻、速递条、图墙、链接行、快捷宫格、公告条)另起 main.css 第 16 节。
契约:block 为渲染端装配的同构 Map(type/title/category.name/homeStyle/items/moreUrl/background/
quickLinks/noticeText/noticeUrl/noticeLevel),与回落模式 categoryBlocks 的块字段兼容;
区块标题一律取 category.name(baseBlock 已把 title 合成进 category.name,两种模式同构)。
三条铁律(手册 §2.5):类名全 sp- 前缀;Map 可选键一律 block['key'] 括号访问(坑②,点号访问缺键抛错);
引用 empty 的空态守卫必须包在 th:block 外层(坑①,th:replace 优先级 100 先于 th:if 300 求值)。
-->
<!-- 形态一:双栏要闻(homeStyle=news_grid)——左 3 份大图文头条、右 2 份紧凑行;区块背景取站点 news_bg_image -->
<section class="sp-section sp-container" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="sp-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="sp-block sp-block--flow"
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.category.name}">校园要闻</h2>
<a class="sp-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<div class="sp-split" th:if="${lead != null}">
<div class="sp-block-featured">
<a th:href="${url.article(lead.id)}">
<span class="sp-feat-cover" th:if="${lead.coverImage != null}">
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
</span>
<span class="sp-feat-title" th:text="${lead.title}">头条标题</span>
<span class="sp-feat-summary" th:if="${!#strings.isEmpty(lead.summary)}"
th:text="${lead.summary}">摘要</span>
<span class="sp-feat-meta">
<span class="sp-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<span class="sp-src" th:if="${!#strings.isEmpty(lead.source)}" th:text="${lead.source}">来源</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</a>
</div>
<ul class="sp-block-list" th:if="${rest != null}">
<li class="sp-block-item" th:each="item : ${rest}">
<a class="sp-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="sp-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="sp-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/scholar-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:默认卡(homeStyle 未设/未知)——整宽头条(左图右文)+ 行式列表 -->
<section class="sp-section sp-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="sp-block sp-block--flow">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="sp-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<div class="sp-block-featured" th:if="${!#lists.isEmpty(block.items)}" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="sp-feat-cover sp-feat-cover--row" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="sp-feat-title" th:text="${feat.title}">头条标题</span>
<span class="sp-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
<span class="sp-feat-meta">
<span class="sp-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<span class="sp-src" th:if="${!#strings.isEmpty(feat.source)}" th:text="${feat.source}">来源</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</a>
</div>
<ul class="sp-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="sp-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a class="sp-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="sp-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="sp-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/scholar-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:日期方块行(homeStyle=compact)——学院蓝最典型的「方块日期 + 标题」行式(复用第 9 节 sp-date-chip) -->
<section class="sp-section sp-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="sp-block sp-block--flow">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.category.name}">通知公告</h2>
<a class="sp-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="sp-block-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="sp-block-item" th:each="item : ${block.items}">
<span class="sp-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="sp-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="sp-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/scholar-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列行式 -->
<section class="sp-section sp-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="sp-block sp-block--flow">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.category.name}">媒体聚焦</h2>
<a class="sp-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="sp-block-list sp-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="sp-block-item" th:each="item : ${block.items}">
<span class="sp-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
<a class="sp-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="sp-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/scholar-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,四列格 + 底部渐变题注 -->
<section class="sp-section sp-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.category.name}">图片新闻</h2>
<a class="sp-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="sp-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="sp-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="sp-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/scholar-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="sp-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="sp-container sp-ticker-inner">
<span class="sp-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="sp-ticker-list">
<li th:each="item : ${block.items}">
<a class="sp-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery)——居中双线标题是本包签名,故不用 --bar 变体 -->
<section class="sp-section sp-container sp-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
th:if="${!#lists.isEmpty(block.items)}">
<div class="sp-block-head">
<h2 th:text="${block.title}">线上展厅</h2>
<i class="sp-block-tick" aria-hidden="true"></i>
</div>
<p class="sp-gallery-sub" th:if="${!#strings.isEmpty(block['subtitle'])}" th:text="${block['subtitle']}">
Online Exhibition Hall
</p>
<ul class="sp-wall">
<li class="sp-wall-item" th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
<span class="sp-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:友情链接分组(block.linkGroups 由渲染端按 group 过滤)——胶囊行 -->
<section class="sp-section sp-container" th:fragment="links(block)" aria-label="友情链接">
<div class="sp-block sp-block--flow">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.title}">友情链接</h2>
</div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="sp-links-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
<ul class="sp-links">
<li th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
</li>
</ul>
</th:block>
</div>
</section>
<!-- 布局模式:快捷通道(quick_links,手册 §2.8 类型 A)——区块内联入口数组,不读友情链接表。
渲染端已把 name 空/url 非法的项丢弃,这里只消费 block['quickLinks']=[{name,url}](括号访问,坑②) -->
<section class="sp-section sp-container" th:fragment="quick_links(block)" aria-label="快捷通道">
<div class="sp-block sp-block--flow">
<div class="sp-block-head sp-block-head--bar">
<h2 th:text="${block.title}">快捷通道</h2>
</div>
<ul class="sp-quick-grid" th:if="${!#lists.isEmpty(block['quickLinks'])}">
<li th:each="l : ${block['quickLinks']}">
<a class="sp-quick-link" th:href="${l.url}" th:text="${l.name}">入口名</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block['quickLinks'])}">
<div th:replace="~{'themes/scholar-blue/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 布局模式:公告条(notice_bar,手册 §2.8 类型 B)——单条手填通告,非滚动非文章列表。
noticeLevel 渲染端已归一为 info|warn;noticeUrl 非法时为空串 → 退化成纯文本 -->
<div class="sp-notice-bar" th:fragment="notice_bar(block)" role="note"
th:if="${!#strings.isEmpty(block['noticeText'])}"
th:classappend="${block['noticeLevel'] == 'warn' ? 'sp-notice--warn' : 'sp-notice--info'}">
<div class="sp-container sp-notice-inner">
<span class="sp-notice-tag" th:text="${block.title}">公告</span>
<a class="sp-notice-link" th:if="${!#strings.isEmpty(block['noticeUrl'])}"
th:href="${block['noticeUrl']}" th:text="${block['noticeText']}">公告正文</a>
<span class="sp-notice-text" th:unless="${!#strings.isEmpty(block['noticeUrl'])}"
th:text="${block['noticeText']}">公告正文</span>
</div>
</div>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="sp-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="sp-empty-hint">内容发布后将在此展示</p>
</div>
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理"。
数据绑定与 default 同构,仅换皮(样式见本包 main.css 第 17 节) -->
<main class="sp-page sp-container" id="main-content">
<div class="sp-page-card sp-svc-guide">
<nav class="sp-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
<a th:href="${url.serviceList()}">服务中心</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
<span class="sp-crumb-current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
</nav>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="sp-empty sp-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="sp-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<article class="sp-svc-guide-body" th:if="${!svcUnavailable}">
<h1 class="sp-article-title" th:text="${item.title}">事项名称</h1>
<div class="sp-article-meta">
<span th:if="${!#strings.isEmpty(item.deptName)}"
th:text="${'办理部门:' + item.deptName}">办理部门</span>
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
<time th:if="${item.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
</div>
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
<div class="sp-svc-apply" th:if="${applyUrl != null}">
<a class="sp-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
<span class="sp-svc-apply-hint">进入服务大厅在线办理</span>
</div>
<p class="sp-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
<table class="sp-svc-info">
<caption class="sp-visually-hidden">事项基本信息</caption>
<tbody>
<tr th:if="${!#strings.isEmpty(item.objectName)}">
<th scope="row">服务对象</th>
<td th:text="${item.objectName}">服务对象</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
<th scope="row">事项类别</th>
<td th:text="${item.categoryName}">事项类别</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
<th scope="row">办理地点</th>
<td th:text="${item.handlePlace}">办理地点</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
<th scope="row">办理时限</th>
<td th:text="${item.handleTimeLimit}">办理时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
<th scope="row">承诺时限</th>
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
<th scope="row">咨询电话</th>
<td th:text="${item.contactPhone}">咨询电话</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
<th scope="row">监督投诉</th>
<td th:text="${item.supervisePhone}">监督投诉电话</td>
</tr>
</tbody>
</table>
<section class="sp-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
<h2>办理流程</h2>
<div class="sp-article-content" th:utext="${item.processHtml}">办理流程</div>
</section>
<section class="sp-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
<h2>所需材料</h2>
<div class="sp-article-content" th:utext="${item.materialHtml}">所需材料</div>
</section>
<section class="sp-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
<h2>注意事项</h2>
<div class="sp-article-content" th:utext="${item.noticeHtml}">注意事项</div>
</section>
<section class="sp-svc-section sp-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
<h2>相关材料下载</h2>
<ul>
<li th:each="att : ${attachments}">
<a th:href="${url.upload(att.webUrl)}" th:download
th:text="${att.fileName}">材料文件</a>
<span class="sp-svc-att-size" th:if="${att.fileSize != null}"
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
</li>
</ul>
</section>
<nav class="sp-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
</nav>
</article>
<aside class="sp-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
<h2 class="sp-svc-related-title">相关服务</h2>
<ul class="sp-svc-related-list">
<li th:each="rel : ${relatedItems}">
<a th:href="${rel.url}">
<span class="sp-svc-related-name" th:text="${rel.title}">相关事项</span>
<span class="sp-svc-related-meta">
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
<span class="sp-tag"
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
</span>
</a>
</li>
</ul>
</aside>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -23,11 +23,31 @@
</div> </div>
</section> </section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) --> <!-- 首页布局模式(docs/16 §4.4 · 手册 §A.2):home_layout 落库后按区块序列渲染。
本包为 A 档:8 形态 + quick_links/notice_bar 全部自持(fragments/home-blocks.html),
只有白名单外的未知类型才落 themes/common 兜底骨架(故 common 的 :: style 仍要引入)。
注意坑④:th:replace(优先级 100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素,
否则未命中的 case 分支也会求值 fragment 表达式(缺键 Map 访问直接抛错整页 500)。 -->
<th:block th:if="${layoutBlocks != null}"> <th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" /> <th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}"> <th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: 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="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
<th:block th:case="'quick_links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: quick_links(${block})}" /></th:block>
<th:block th:case="'notice_bar'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: notice_bar(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block> </th:block>
</th:block> </th:block>
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query
(数据绑定与 default 同构,仅换皮;样式见本包 main.css 第 17 节) -->
<main class="sp-page sp-container" id="main-content">
<div class="sp-page-card sp-svc-page">
<nav class="sp-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="sp-crumb-sep" aria-hidden="true">/</span>
<span class="sp-crumb-current">服务中心</span>
</nav>
<h1 class="sp-page-title">服务中心</h1>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="sp-empty sp-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="sp-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<div class="sp-svc-filters"
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
<div class="sp-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
<span class="sp-svc-filter-label">服务对象</span>
<div class="sp-svc-filter-options">
<a class="sp-chip" th:each="opt : ${serviceFilters.objects}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="sp-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
<span class="sp-svc-filter-label">事项类别</span>
<div class="sp-svc-filter-options">
<a class="sp-chip" th:each="opt : ${serviceFilters.categories}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="sp-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
<span class="sp-svc-filter-label">服务部门</span>
<div class="sp-svc-filter-options">
<a class="sp-chip" th:each="opt : ${serviceFilters.depts}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
</div>
<div class="sp-svc-toolbar" th:if="${!svcUnavailable}">
<form class="sp-svc-search" th:action="${url.serviceList()}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
<button type="submit">搜索</button>
</form>
<a class="sp-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
</div>
<ul class="sp-svc-list" th:if="${!svcUnavailable}">
<li th:each="it : ${serviceList.items}">
<a class="sp-svc-item" th:href="${url.serviceItem(it.id)}">
<span class="sp-svc-item-cover" th:if="${it.coverImage != null}">
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
</span>
<span class="sp-svc-item-body">
<span class="sp-svc-item-title">
<span class="sp-svc-item-name" th:text="${it.title}">事项名称</span>
<span class="sp-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
<span class="sp-tag"
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
</span>
<span class="sp-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
th:text="${it.summary}">简介</span>
<span class="sp-svc-item-meta">
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
<time th:if="${it.publishTime != null}"
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<div class="sp-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
<p class="sp-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
<p class="sp-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
</div>
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
<nav class="sp-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
<a th:each="p : ${serviceList.pageUrls}"
th:classappend="${p.active} ? 'active'"
th:href="${p.url}"
th:aria-label="${'第' + p.page + '页'}"
th:aria-current="${p.active} ? 'page'"
th:text="${p.page}">1</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -17,6 +17,11 @@
{ "key": "textLight", "cssVar": "--sp-text-light", "label": "次要文字色", "type": "color", "default": "#777777" }, { "key": "textLight", "cssVar": "--sp-text-light", "label": "次要文字色", "type": "color", "default": "#777777" },
{ "key": "bg", "cssVar": "--sp-bg", "label": "页面底色", "type": "color", "default": "#eff2f6" }, { "key": "bg", "cssVar": "--sp-bg", "label": "页面底色", "type": "color", "default": "#eff2f6" },
{ "key": "cardBg", "cssVar": "--sp-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" }, { "key": "cardBg", "cssVar": "--sp-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--sp-border", "label": "边框色", "type": "color", "default": "#e3e7ee" } { "key": "border", "cssVar": "--sp-border", "label": "边框色", "type": "color", "default": "#e3e7ee" },
{ "key": "headerStyle", "label": "头部样式", "type": "select", "default": "banner",
"options": [
{ "value": "banner", "label": "深蓝顶栏+品牌行+白底导航(完整学院派)" },
{ "value": "plain", "label": "简洁顶栏(收起深蓝细条、压低品牌行)" }
] }
] ]
} }
@@ -30,8 +30,10 @@ class PortalPageRendererTest {
@DisplayName("主题缺可选模板 → 整页回退 default") @DisplayName("主题缺可选模板 → 整页回退 default")
void missingTemplate_fallsBackToDefault() { void missingTemplate_fallsBackToDefault() {
ThemeRegistry registry = mock(ThemeRegistry.class); ThemeRegistry registry = mock(ThemeRegistry.class);
// 桩必须指向"确实不存在"的资源:scholar-blue 自 P-A1 起已备有 service.html,
// 再用真实路径就让 .exists() 变真,用例守的"缺模板整页回退"语义就丢了。
when(registry.resource("scholar-blue", "service.html")) when(registry.resource("scholar-blue", "service.html"))
.thenReturn(new ClassPathResource("templates/themes/scholar-blue/service.html")); .thenReturn(new ClassPathResource("templates/themes/scholar-blue/__absent__.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry); PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html")) assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html"))
@@ -169,6 +169,44 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
assertThat(count(html, "cg-news-title")).isEqualTo(10); assertThat(count(html, "cg-news-title")).isEqualTo(10);
} }
@Test
@DisplayName("T-B-04 装配 quick_links:默认标题「快捷通道」,url 原样直出,非法/缺地址项静默丢弃不打挂渲染")
void layout_quickLinks_dropsIllegalItems() throws Exception {
setConfig("layhome", "{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"quick_links\",\"items\":["
+ "{\"name\":\"办事大厅\",\"url\":\"https://bsch.edu.cn/hall\"},"
+ "{\"name\":\"图书馆\",\"url\":\"/library\"},"
+ "{\"name\":\"内联脚本入口\",\"url\":\"javascript:alert(1)\"},"
+ "{\"name\":\"缺地址入口\"}]}]}}");
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(html).contains("plb-quick-links").contains("快捷通道");
assertThat(count(html, "<a class=\"plb-quick-link\"")).as("4 项中 2 项地址非法 → 丢弃后渲染 2 项").isEqualTo(2);
assertThat(html).contains("href=\"https://bsch.edu.cn/hall\"").contains("办事大厅");
assertThat(html).contains("href=\"/library\"").contains("图书馆");
assertThat(html).as("脏数据不得出现在页面").doesNotContain("内联脚本入口").doesNotContain("缺地址入口")
.doesNotContain("javascript:");
}
@Test
@DisplayName("T-B-05 装配 notice_bar:noticeText/noticeUrl/noticeLevel 三键直出,level 缺省 info、非法 url 降级为纯文本")
void layout_noticeBar_threeKeys() throws Exception {
setConfig("layhome", "{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"notice_bar\",\"title\":\"放假通知\",\"text\":\"国庆放假安排已发布\",\"url\":\"/notice/gq\",\"level\":\"warn\"},"
+ "{\"id\":\"b2\",\"type\":\"notice_bar\",\"text\":\"常规提示\",\"url\":\"javascript:alert(2)\"}"
+ "]}}");
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(count(html, "<div class=\"plb-notice-bar")).as("两块均渲染").isEqualTo(2);
assertThat(html).contains("国庆放假安排已发布").contains("常规提示");
assertThat(html).as("level 显式 warn / 缺省归一 info").contains("plb-notice-warn").contains("plb-notice-info");
assertThat(count(html, "<a class=\"plb-notice-link\"")).as("仅合法 url 的块带链接").isEqualTo(1);
assertThat(html).contains("href=\"/notice/gq\"");
assertThat(html).doesNotContain("javascript:");
}
@Test @Test
@DisplayName("非法 home_layout(blocks 非数组/type 白名单外):容错回落现状,门户不打挂") @DisplayName("非法 home_layout(blocks 非数组/type 白名单外):容错回落现状,门户不打挂")
void invalidLayout_fallsBack() throws Exception { void invalidLayout_fallsBack() throws Exception {
@@ -118,12 +118,17 @@ class ThemeRenderIT extends CmsIntegrationTestBase {
} }
@Test @Test
@DisplayName("T13: 微调参数注入 CSS 变量且位于皮肤 CSS link 之后") @DisplayName("T13(工作项 T 改写): 包级 paramValues 注入 CSS 变量且位于皮肤 CSS link 之后;站点 config_json.theme_params 残留值不再生效")
void themeParamsInjectedAfterSkinCss() throws Exception { void packageParamValuesInjectedAfterSkinCss() throws Exception {
persistSiteWithConfig("inj1", "{\"theme_params\":{\"primary\":\"#b01a2e\"}}"); java.util.Map<String, String> values = new java.util.LinkedHashMap<>();
values.put("primary", "#b01a2e");
themePackageService.updateMeta(TestUsers.superAdmin(), "default", null, null, null, values);
// 站点级残留(改造前该键是取值来源):门户必须不再读它,否则包级/站点级两套值打架
persistSiteWithConfig("inj1", "{\"theme_params\":{\"primary\":\"#111111\"}}");
String html = homeHtml("inj1"); String html = homeHtml("inj1");
assertThat(html).as("合法色值应注入").contains("--primary:#b01a2e"); assertThat(html).as("包级合法色值应注入").contains("--primary:#b01a2e");
assertThat(html).as("站点级 theme_params 已退役,残留值不得影响门户").doesNotContain("#111111");
int cssLinkIndex = html.indexOf("css/main.css"); int cssLinkIndex = html.indexOf("css/main.css");
int varIndex = html.indexOf("--primary:#b01a2e"); int varIndex = html.indexOf("--primary:#b01a2e");
assertThat(cssLinkIndex).as("皮肤 CSS link 应存在").isGreaterThan(-1); assertThat(cssLinkIndex).as("皮肤 CSS link 应存在").isGreaterThan(-1);
@@ -34,6 +34,10 @@ class PortalQueryServiceCacheIT extends CmsIntegrationTestBase {
private SiteService siteService; private SiteService siteService;
@Autowired @Autowired
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
@Autowired
private com.yunhai.cms.theme.ThemeRegistry themeRegistry;
@Autowired
private com.yunhai.cms.theme.ThemePackageService themePackageService;
private Integer siteId; private Integer siteId;
private Integer categoryId; private Integer categoryId;
@@ -213,26 +217,37 @@ class PortalQueryServiceCacheIT extends CmsIntegrationTestBase {
} }
@Test @Test
@DisplayName("T17: 改 theme_params 后门户立即生效(保存事件驱逐 cms:site,快照携带新值)") @DisplayName("T17: 改皮肤包级参数值后门户立即生效(保存事件驱逐 cms:site,快照携带新值)")
void siteSnapshot_themeParamsRefreshedAfterSave() { void siteSnapshot_packageParamValuesRefreshedAfterSave() throws Exception {
// 工作项 T:参数微调从站点级迁到皮肤包级(themes/.meta/{name}.json),门户读取口径同步改。
// 临时外部目录隔离覆盖层写入,避免污染 ${user.dir}/data/themes。
String originalThemeDir = themeRegistry.getThemeDir();
java.nio.file.Path themeDir = java.nio.file.Files.createTempDirectory("t-t17-themes");
try {
themeRegistry.setThemeDir(themeDir.toString());
themePackageService.setThemeDir(themeDir.toString());
themeRegistry.reload();
clearCaches();
assertThat(portalQueryService.siteSnapshot("demo1").getThemeParams()) assertThat(portalQueryService.siteSnapshot("demo1").getThemeParams())
.as("初始快照无微调参数").isEmpty(); .as("包未微调时快照无参数值(theme.json 默认值不算微调)").isEmpty();
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setId(siteId);
req.setCode("demo1");
req.setSchId(1);
req.setSchName("学校-1");
req.setName("演示站点一");
java.util.Map<String, String> params = new java.util.LinkedHashMap<>(); java.util.Map<String, String> params = new java.util.LinkedHashMap<>();
params.put("primary", "#b01a2e"); params.put("primary", "#b01a2e");
req.setThemeParams(params); themePackageService.updateMeta(TestUsers.superAdmin(),
siteService.save(TestUsers.superAdmin(), req); com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME, null, null, null, params);
PortalSiteSnapshot fresh = portalQueryService.siteSnapshot("demo1"); PortalSiteSnapshot fresh = portalQueryService.siteSnapshot("demo1");
assertThat(fresh.getThemeParams()).as("保存事件驱逐快照后应携带新参数") assertThat(fresh.getThemeParams()).as("包级参数写入事件驱逐快照后应携带新值")
.containsEntry("primary", "#b01a2e"); .containsEntry("primary", "#b01a2e");
assertThat(fresh.getThemeVarsCss()).contains("--primary:#b01a2e"); assertThat(fresh.getThemeVarsCss()).contains("--primary:#b01a2e");
} finally {
themeRegistry.setThemeDir(originalThemeDir);
themePackageService.setThemeDir(originalThemeDir);
themeRegistry.reload();
clearCaches();
com.yunhai.cms.support.ThemePackageTestSupport.deleteRecursively(themeDir);
}
} }
// ===== 站内搜索(实时不缓存)===== // ===== 站内搜索(实时不缓存)=====
@@ -129,6 +129,102 @@ class SiteServiceHomeLayoutIT extends CmsIntegrationTestBase {
assertThat(siteService.save(TestUsers.superAdmin(), own)).isNotNull(); assertThat(siteService.save(TestUsers.superAdmin(), own)).isNotNull();
} }
@Test
@DisplayName("T-B-01 白名单扩容:quick_links / notice_bar 合法结构可保存,近似类型名仍拒")
void save_newBlockTypes() {
CmsSite site = persistSite("lay6", 1, "新区块站");
assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"quicklink\"}]}}", "类型不合法");
assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"noticebar\"}]}}", "类型不合法");
assertAccept(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"b1\",\"type\":\"quick_links\",\"title\":\"服务入口\",\"items\":["
+ "{\"name\":\"办事大厅\",\"url\":\"https://bsch.edu.cn/hall\"},{\"name\":\"图书馆\",\"url\":\"/library\"}]},"
+ "{\"id\":\"b2\",\"type\":\"notice_bar\",\"text\":\"期中考试安排已发布\"}"
+ "]}}");
}
@Test
@DisplayName("T-B-02 quick_links:items 缺失/非数组/空/超 12 项、项非对象、name 缺失或超 20 字、url 前缀非法 → 拒绝")
void validate_quickLinksStructures() {
CmsSite site = persistSite("lay7", 1, "快捷站");
Integer id = site.getId();
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\"}]}}", "items");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":\"bad\"}]}}", "数组");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":[]}]}}", "至少");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":[\"bad\"]}]}}", "对象");
assertReject(id, quickLinksJson(13), "12");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":[{\"name\":\"图书馆\"}]}]}}", "url");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":[{\"url\":\"/library\"}]}]}}", "name");
StringBuilder longName = new StringBuilder();
for (int i = 0; i < 21; i++) {
longName.append("长");
}
assertReject(id, oneLinkJson(longName.toString(), "/library"), "20");
assertReject(id, oneLinkJson("办事大厅", "javascript:alert(1)"), "http(s)://");
assertReject(id, oneLinkJson("办事大厅", "library"), "http(s)://");
// 12 项为上限内的合法边界
assertAccept(id, quickLinksJson(12));
}
@Test
@DisplayName("T-B-03 notice_bar:text 缺失/空白/非文本/超 200 字、level 非 info/warn、url 前缀非法 → 拒绝")
void validate_noticeBarStructures() {
CmsSite site = persistSite("lay8", 1, "公告站");
Integer id = site.getId();
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\"}]}}", "text");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\"\"}]}}", "text");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\" \"}]}}", "text");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":123}]}}", "text");
StringBuilder longText = new StringBuilder();
for (int i = 0; i < 201; i++) {
longText.append("告");
}
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\"" + longText + "\"}]}}", "200");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\"放假通知\",\"level\":\"danger\"}]}}", "level");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\"放假通知\",\"level\":1}]}}", "level");
assertReject(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\"放假通知\",\"url\":\"javascript:alert(1)\"}]}}", "http(s)://");
// 200 字边界 + url/level 可选
StringBuilder maxText = new StringBuilder();
for (int i = 0; i < 200; i++) {
maxText.append("告");
}
assertAccept(id, "{\"home_layout\":{\"blocks\":[{\"type\":\"notice_bar\",\"text\":\"" + maxText
+ "\",\"url\":\"https://news.edu.cn/1\",\"level\":\"warn\"},"
+ "{\"type\":\"notice_bar\",\"text\":\"常规提示\",\"level\":\"info\"}]}}");
}
/** 单项 quick_links 区块配置 */
private static String oneLinkJson(String name, String url) {
return "{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":[{\"name\":\"" + name
+ "\",\"url\":\"" + url + "\"}]}]}}";
}
/** n 项合法 quick_links 区块配置 */
private static String quickLinksJson(int n) {
StringBuilder sb = new StringBuilder("{\"home_layout\":{\"blocks\":[{\"type\":\"quick_links\",\"items\":[");
for (int i = 1; i <= n; i++) {
sb.append("{\"name\":\"入口").append(i).append("\",\"url\":\"/entry").append(i).append("\"}");
if (i < n) {
sb.append(',');
}
}
return sb.append("]}]}}").toString();
}
private void assertAccept(Integer siteId, String configJson) {
SiteSaveRequest req = new SiteSaveRequest();
req.setId(siteId);
req.setCode(siteService.requireManageable(TestUsers.superAdmin(), siteId).getCode());
req.setName("校验站");
req.setSchId(1);
req.setConfigJson(configJson);
assertThat(siteService.save(TestUsers.superAdmin(), req)).isNotNull();
}
private void assertReject(Integer siteId, String configJson, String messagePart) { private void assertReject(Integer siteId, String configJson, String messagePart) {
SiteSaveRequest req = new SiteSaveRequest(); SiteSaveRequest req = new SiteSaveRequest();
req.setId(siteId); req.setId(siteId);
@@ -268,12 +268,12 @@ class SiteServiceIT extends CmsIntegrationTestBase {
} }
@Test @Test
@DisplayName("T11: themeParams 服务端合并进 config_json.theme_params 且保留其余键") @DisplayName("T11(工作项 T 改写): 参数值已迁皮肤包级——themeParams 入参被服务端忽略,不再写 config_json.theme_params,其余键保留")
void save_themeParamsMergePreservesOtherConfigKeys() { void save_themeParamsIgnoredOtherConfigKeysPreserved() {
SiteSaveRequest req = new SiteSaveRequest(); SiteSaveRequest req = new SiteSaveRequest();
req.setCode("theme3"); req.setCode("theme3");
req.setSchId(1); req.setSchId(1);
req.setName("参数合并站"); req.setName("参数退役站");
req.setConfigJson("{\"logo\":\"/upload/cms/logo.png\",\"contact\":\"0571-88888888\"}"); req.setConfigJson("{\"logo\":\"/upload/cms/logo.png\",\"contact\":\"0571-88888888\"}");
Map<String, String> params = new LinkedHashMap<>(); Map<String, String> params = new LinkedHashMap<>();
params.put("primary", "#ff0000"); params.put("primary", "#ff0000");
@@ -283,16 +283,15 @@ class SiteServiceIT extends CmsIntegrationTestBase {
CmsSite saved = siteService.getByCode("theme3"); CmsSite saved = siteService.getByCode("theme3");
Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson()); Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson());
assertThat(config).as("其余保留键不得被合并覆盖") assertThat(config).as("其余保留键不得受影响")
.containsEntry("logo", "/upload/cms/logo.png") .containsEntry("logo", "/upload/cms/logo.png")
.containsEntry("contact", "0571-88888888"); .containsEntry("contact", "0571-88888888");
assertThat(config.get("theme_params")).isInstanceOf(Map.class); assertThat(saved.getConfigJson()).as("站点不再持有参数值(老前端仍提交该字段也不落库)")
assertThat(((Map<?, ?>) config.get("theme_params")).get("primary")) .doesNotContain("theme_params").doesNotContain("#ff0000");
.as("声明过的 key 应写入").isEqualTo("#ff0000");
} }
@Test @Test
@DisplayName("T12: themeParams 未声明 key 服务端丢弃,不落库") @DisplayName("T12: themeParams 未声明 key 服务端丢弃,不落库(工作项 T:站点级参数整体退役)")
void save_undeclaredThemeParamKeyDropped() { void save_undeclaredThemeParamKeyDropped() {
SiteSaveRequest req = new SiteSaveRequest(); SiteSaveRequest req = new SiteSaveRequest();
req.setCode("theme4"); req.setCode("theme4");
@@ -305,9 +304,12 @@ class SiteServiceIT extends CmsIntegrationTestBase {
siteService.save(TestUsers.superAdmin(), req); siteService.save(TestUsers.superAdmin(), req);
CmsSite saved = siteService.getByCode("theme4"); CmsSite saved = siteService.getByCode("theme4");
assertThat(saved.getConfigJson()).as("未声明 key 不得落库").doesNotContain("zzhack"); // 断言改为 Map 口径:参数合并路径退役后 config_json 不再被写为 "{}"(未提交配置即为 null),
// 原 doesNotContain(String) 对 null 直接失败;本用例语义不变——未声明 key 不得落库。
assertThat(com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson()))
.as("未声明 key 不得落库").doesNotContainKey("zzhack");
Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson()); Map<String, Object> config = com.yunhai.cms.utils.ExtJsonUtils.parse(saved.getConfigJson());
assertThat(config).as("过滤后无有效参数 → 不写入 theme_params 键") assertThat(config).as("站点不再持有 theme_params 键")
.doesNotContainKey("theme_params"); .doesNotContainKey("theme_params");
} }
@@ -281,6 +281,65 @@ class ThemePackageServiceTest {
assertThat(registry.get("default").getTitle()).isEqualTo("覆盖名"); assertThat(registry.get("default").getTitle()).isEqualTo("覆盖名");
} }
// ===== P-T1 参数迁包级(工作项 T):包级参数值写入 =====
@Test
@DisplayName("T-T-02: updateMeta 写内置包 paramValues——落 .meta 覆盖层、按 schema 丢弃未声明 key、reload 后仍生效")
void updateMetaWritesParamValuesForBuiltinPackage() throws Exception {
java.util.Map<String, String> values = new java.util.LinkedHashMap<>();
values.put("primary", "#0a5c36");
values.put("zz_undeclared", "#ff0000");
ThemeInfo info = service.updateMeta(TestUsers.superAdmin(), "default", null, null, null, values);
assertThat(info.getParamValues()).as("声明过的 key 写入包级参数值")
.containsEntry("primary", "#0a5c36");
assertThat(info.getParamValues()).as("schema 未声明的 key 丢弃").doesNotContainKey("zz_undeclared");
String overlay = new String(Files.readAllBytes(themeDir.resolve(".meta").resolve("default.json")),
StandardCharsets.UTF_8);
assertThat(overlay).as("内置包值写覆盖层,不碰 jar 内 theme.json")
.contains("\"paramValues\"").contains("#0a5c36");
registry.reload();
assertThat(registry.paramValuesOf("default")).as("reload(等价重启)后仍生效")
.containsEntry("primary", "#0a5c36");
}
@Test
@DisplayName("T-T-02b: paramValues 传空 Map=清空该包全部微调恢复默认;传 null=不改(title/description 逻辑不受影响)")
void emptyParamValuesMapResetsAndNullKeepsOverlay() throws Exception {
java.util.Map<String, String> values = new java.util.LinkedHashMap<>();
values.put("primary", "#0a5c36");
service.updateMeta(TestUsers.superAdmin(), "default", "内置改名", null, null, values);
assertThat(registry.paramValuesOf("default")).containsEntry("primary", "#0a5c36");
// null=不改:只改描述时 paramValues 保留
service.updateMeta(TestUsers.superAdmin(), "default", null, "只改描述", null, null);
assertThat(registry.paramValuesOf("default")).containsEntry("primary", "#0a5c36");
assertThat(registry.get("default").getTitle()).isEqualTo("内置改名");
// 空 Map=清空参数值,但覆盖层(仍持有 title)不得被删除
service.updateMeta(TestUsers.superAdmin(), "default", null, null, null,
new java.util.LinkedHashMap<String, String>());
assertThat(registry.paramValuesOf("default")).isEmpty();
assertThat(Files.exists(themeDir.resolve(".meta").resolve("default.json"))).isTrue();
assertThat(registry.get("default").getTitle()).isEqualTo("内置改名");
}
@Test
@DisplayName("T-T-02c: 复制皮肤包一并带过 paramValues(值在 .meta 覆盖层、不在包目录内,v1 §3.4)")
void deriveCopiesPackageParamValues() {
java.util.Map<String, String> values = new java.util.LinkedHashMap<>();
values.put("primary", "#0a5c36");
service.updateMeta(TestUsers.superAdmin(), "default", null, null, null, values);
ThemeInfo derived = service.derive(TestUsers.superAdmin(), "default", "t-derive-params", null, null);
assertThat(derived.getParamValues()).as("新包初始值 = 源包当前值").containsEntry("primary", "#0a5c36");
assertThat(registry.paramValuesOf("t-derive-params")).containsEntry("primary", "#0a5c36");
assertThat(registry.paramValuesOf("default")).as("源包不受影响").containsEntry("primary", "#0a5c36");
}
/** JDK8 递归删除(测试临时目录专用) */ /** JDK8 递归删除(测试临时目录专用) */
private static void deleteRecursively(Path path) { private static void deleteRecursively(Path path) {
if (!Files.exists(path)) { if (!Files.exists(path)) {
@@ -0,0 +1,147 @@
package com.yunhai.cms.theme;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 首页区块门禁纯单测(v2 手册 §2.5 坑⑤ / 工作项 B):
* 门禁**不得**把 fragments/home-blocks.html 设为包必需项(B 档包整段走 common 兜底,强制齐全会剔除
* + 静默回落 default);只做「引用一致性」——home.html 分发给本包的形态,本包文件必须定义。
* 不启 Spring、不连库:existsFn/readFn 用 Map 桩(与 ThemeRegistry 注入的两套实现同契约)。
*/
@DisplayName("ThemePackageValidator - 首页区块门禁(引用一致而非 8 形态强制)")
class ThemePackageValidatorTest {
private static final String HOME = "home.html";
private static final String BLOCKS = "fragments/home-blocks.html";
/** 一个除 home/home-blocks 文本外完全合规的包清单 */
private static ThemeInfo pkg() {
ThemeInfo info = new ThemeInfo();
info.setSpecVersion(1);
info.setName("t-gate");
info.setScreenshot("assets/screenshot.png");
return info;
}
/** 必备模板与截图恒存在;home-blocks 的存在性由 texts 决定(读不到文本即视为不存在) */
private static Predicate<String> exists(Map<String, String> texts) {
return rel -> texts.containsKey(rel) || rel.equals("list.html") || rel.equals("detail.html")
|| rel.equals("single.html") || rel.equals("fragments/head.html")
|| rel.equals("assets/screenshot.png");
}
private static Function<String, String> reader(Map<String, String> texts) {
return texts::get;
}
/** home.html 里把若干形态分发给本包 home-blocks(${themeBase} 拼接写法,与 poly-blue 同构) */
private static String dispatch(String... forms) {
StringBuilder sb = new StringBuilder("<main><th:block th:switch=\"${block.type}\">");
for (String form : forms) {
sb.append("<th:block th:case=\"'").append(form).append("\"")
.append("><th:block th:replace=\"~{${themeBase} + '/fragments/home-blocks' :: ")
.append(form).append("(${block})}\" /></th:block>");
}
return sb.append("</th:block></main>").toString();
}
private static String defines(String... forms) {
StringBuilder sb = new StringBuilder();
for (String form : forms) {
sb.append("<section th:fragment=\"").append(form).append("(block)\">x</section>");
}
return sb.toString();
}
private static Map<String, String> texts(String home, String blocks) {
Map<String, String> map = new HashMap<>();
map.put(HOME, home);
if (blocks != null) {
map.put(BLOCKS, blocks);
}
return map;
}
@Test
@DisplayName("坑⑤①:home.html 未引用本包 home-blocks 且无该文件 → 不剔除(B 档 7 套包同构)")
void packageWithoutHomeBlocksPasses() {
Map<String, String> texts = texts("<main><section th:text=\"'x'\"></section></main>", null);
assertThatCode(() -> ThemePackageValidator.validate(pkg(), "t-gate", exists(texts), reader(texts)))
.as("无 home-blocks 不是剔除理由(门禁不得设为必需项)")
.doesNotThrowAnyException();
}
@Test
@DisplayName("坑⑤②:只实现部分形态且未引用的形态不落要求(campus-glow 现状:5 引用 5 定义)")
void partialCoverageIsJudgedByReferencesNotByEightForms() {
Map<String, String> texts = texts(dispatch("news_grid", "media_focus", "card", "ticker", "gallery"),
defines("news_grid", "media_focus", "card", "ticker", "gallery"));
assertThatCode(() -> ThemePackageValidator.validate(pkg(), "t-gate", exists(texts), reader(texts)))
.as("compact/image_wall/links 已由 home.html 交给 common 兜底 → 不得要求本包齐全")
.doesNotThrowAnyException();
// 反面:若按"含全部 8 个形态"的字面口径,本包会被剔除(= 手册 §2.5 坑⑤ 的事故)
assertThat(ThemePackageValidator.ownHomeBlockRefs(texts.get(HOME), "t-gate")).hasSize(5);
}
@Test
@DisplayName("引用 themes/common 兜底骨架不算本包引用(默认 case 与整段兜底写法)")
void commonFallbackReferenceIsNotOwnReference() {
String home = "<th:block th:replace=\"~{'themes/common/fragments/home-blocks' :: render(${block})}\" />"
+ "<th:block th:replace=\"~{'themes/common/fragments/home-blocks' :: plbCompact(${block})}\" />";
Map<String, String> texts = texts(home, null);
assertThat(ThemePackageValidator.ownHomeBlockRefs(home, "t-gate")).isEmpty();
assertThatCode(() -> ThemePackageValidator.validate(pkg(), "t-gate", exists(texts), reader(texts)))
.doesNotThrowAnyException();
}
@Test
@DisplayName("T-B-10:引用形态在 home-blocks 中未定义 → 抛异常且消息列出缺项")
void danglingReferenceListsMissingForms() {
Map<String, String> texts = texts(
dispatch("news_grid", "compact", "image_wall"),
defines("news_grid", "compact"));
assertThatThrownBy(() -> ThemePackageValidator.validate(pkg(), "t-gate", exists(texts), reader(texts)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(BLOCKS)
.hasMessageContaining("缺 fragment 定义:image_wall")
.hasMessageNotContaining("缺 fragment 定义:news_grid");
}
@Test
@DisplayName("T-B-11:home.html 分发给本包但本包无该文件 → 抛异常并列出引用形态清单")
void missingFileListsReferencedForms() {
Map<String, String> texts = texts(dispatch("card", "gallery"), null);
assertThatThrownBy(() -> ThemePackageValidator.validate(pkg(), "t-gate", exists(texts), reader(texts)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("该文件不存在")
.hasMessageContaining("card")
.hasMessageContaining("gallery");
}
@Test
@DisplayName("字面路径 themes/<目录名>/ 写法同样计入本包引用;他包字面路径不算")
void literalPathFormsAreRecognised() {
String own = "<th:block th:replace=\"~{'themes/t-gate/fragments/home-blocks' :: links(${block})}\" />";
String other = "<th:block th:replace=\"~{'themes/poly-blue/fragments/home-blocks' :: card(${block})}\" />";
assertThat(ThemePackageValidator.ownHomeBlockRefs(own, "t-gate")).containsExactly("links");
assertThat(ThemePackageValidator.ownHomeBlockRefs(other, "t-gate")).as("派生他包不替它背锅").isEmpty();
}
@Test
@DisplayName("fragment 名解析兼容多签名与带参定义")
void fragmentNameParsing() {
assertThat(ThemePackageValidator.fragmentNamesOf(
"<div th:fragment=\"a(b)\"></div><div th:fragment=\"c d\"></div><div th:fragment=\"e\"></div>"))
.contains("a", "c", "d", "e");
}
}
@@ -208,6 +208,32 @@ class ThemeRegistryTest {
.doesNotContainKey("heroImage"); .doesNotContainKey("heroImage");
} }
// ===== P-T1 参数迁包级(工作项 T):.meta 覆盖层承载 paramValues =====
@Test
@DisplayName("T-T-01: .meta 覆盖层的 paramValues 被注册表读取并经 paramValuesOf 暴露")
void metaOverlayParamValuesExposed() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("pt1-themes");
writeExternalPackage(dir, "t-ext-params", null);
java.nio.file.Files.createDirectories(dir.resolve(".meta"));
java.nio.file.Files.write(dir.resolve(".meta").resolve("t-ext-params.json"),
"{\"paramValues\":{\"primary\":\"#0a5c36\"}}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.get("t-ext-params").getParamValues())
.as("覆盖层 paramValues 必须进入注册表(包级参数值的唯一来源)")
.containsEntry("primary", "#0a5c36");
assertThat(r.paramValuesOf("t-ext-params")).containsEntry("primary", "#0a5c36");
assertThat(r.paramValuesOf("t-demo-red"))
.as("无覆盖层的包返回空 Map(值回落 theme.json defaultValue)").isEmpty();
assertThat(r.paramValuesOf("no-such-theme"))
.as("未注册主题返回空 Map,调用方免判空").isEmpty();
deleteRecursively(dir);
}
// ===== W1 外部主题目录(方案 §5.3 T-W1-01..04)===== // ===== W1 外部主题目录(方案 §5.3 T-W1-01..04)=====
/** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */ /** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */
@@ -308,6 +334,65 @@ class ThemeRegistryTest {
deleteRecursively(dir); deleteRecursively(dir);
} }
// ===== v2 工作项 B:首页区块门禁补强(手册 §2.5 坑⑤:引用一致而非 8 形态强制)=====
@Test
@DisplayName("T-B-10 坑⑤回归:home.html 未引用本包 home-blocks 且无该文件的外部包必须照常注册")
void packageWithoutOwnHomeBlocksIsNotEvicted() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("gate-themes");
java.nio.file.Path pkg = writeExternalPackage(dir, "t-ext-nohb", null);
assertThat(java.nio.file.Files.exists(pkg.resolve("home.html"))).isTrue();
assertThat(java.nio.file.Files.exists(pkg.resolve("fragments").resolve("home-blocks.html")))
.as("夹具包本就没有 home-blocks(B 档包同构)").isFalse();
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("t-ext-nohb"))
.as("缺 home-blocks 不得成为剔除理由,否则 7 套 B 档包静默回落 default").isTrue();
deleteRecursively(dir);
}
@Test
@DisplayName("T-B-11 home.html 把未定义的形态分发给本包 home-blocks → 该包剔除,同目录健康包不受影响")
void danglingHomeBlockReferenceEvictsOnlyThatPackage() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("gate-themes");
java.nio.file.Path pkg = writeExternalPackage(dir, "t-ext-dangling", null);
// compact 有定义、image_wall 无定义(模拟派生包补形态时漏写一个)
java.nio.file.Files.write(pkg.resolve("fragments").resolve("home-blocks.html"),
"<section th:fragment=\"compact(block)\">c</section>".getBytes(java.nio.charset.StandardCharsets.UTF_8));
String home = new String(java.nio.file.Files.readAllBytes(pkg.resolve("home.html")),
java.nio.charset.StandardCharsets.UTF_8);
java.nio.file.Files.write(pkg.resolve("home.html"),
(home + dispatchToOwnHomeBlocks("compact") + dispatchToOwnHomeBlocks("image_wall"))
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
writeExternalPackage(dir, "t-ext-neighbour", null);
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("t-ext-dangling"))
.as("引用了本包不存在的 fragment → 首页必 500,必须剔除").isFalse();
assertThat(r.contains("t-ext-neighbour"))
.as("剔除范围仅限坏包,不得连带误杀").isTrue();
deleteRecursively(dir);
}
@Test
@DisplayName("T-B-12 门禁补强后 13 套内置包零剔除(含只做 5 形态的 campus-glow)")
void allBuiltinPackagesSurviveTheGate() {
assertThat(registry.list()).extracting(ThemeInfo::getName).contains(
"default", "classic-red", "blue-white", "warm-kinder", "fresh-mint", "mint-breeze",
"scholar-blue", "campus-glow", "academic-navy", "campus-azure", "poly-blue",
"agri-forest", "jade-normal");
}
/** 与 poly-blue/home.html 同构的分发写法:~{${themeBase} + '/fragments/home-blocks' :: 形态(${block})} */
private static String dispatchToOwnHomeBlocks(String form) {
return "<th:block th:case=\"'" + form + "'\"><th:block th:replace=\"~{${themeBase} + "
+ "'/fragments/home-blocks' :: " + form + "(${block})}\" /></th:block>";
}
/** JDK8 递归删除(测试临时目录专用) */ /** JDK8 递归删除(测试临时目录专用) */
private static void deleteRecursively(java.nio.file.Path path) { private static void deleteRecursively(java.nio.file.Path path) {
if (!java.nio.file.Files.exists(path)) { if (!java.nio.file.Files.exists(path)) {