feat(theme): S5 学院蓝主题改造——home-blocks/service/guide页+区块CSS+ThemeRegistry增强
build / build-push (push) Successful in 53s
build / build-push (push) Successful in 53s
This commit is contained in:
@@ -69,13 +69,14 @@ public class ThemeController {
|
||||
}
|
||||
|
||||
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
|
||||
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效 */
|
||||
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效。
|
||||
* 工作项 T:paramValues(包级参数值)两种来源统一写 .meta 覆盖层,null=不改 */
|
||||
@PutMapping("/{name}/meta")
|
||||
public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept,
|
||||
@PathVariable("name") String name,
|
||||
@RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) {
|
||||
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
|
||||
req.getTitle(), req.getDescription(), req.getParams());
|
||||
req.getTitle(), req.getDescription(), req.getParams(), req.getParamValues());
|
||||
return themeService.voOf(info);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
||||
@@ -288,6 +289,8 @@ public class PortalPageRenderer {
|
||||
private static final int LAYOUT_SIZE_MAX = 20;
|
||||
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
|
||||
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
|
||||
/** 区块内手填链接:http(s) 绝对地址或站内根相对路径(与 SiteService 保存期校验同契约,拒绝伪协议) */
|
||||
private static final Pattern LAYOUT_LINK_URL = Pattern.compile("^(https?://\\S+|/.+)$");
|
||||
|
||||
/**
|
||||
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
|
||||
@@ -341,6 +344,10 @@ public class PortalPageRenderer {
|
||||
return buildGalleryBlock(site, spec, url);
|
||||
case "links":
|
||||
return buildLinksBlock(site, spec);
|
||||
case "quick_links":
|
||||
return buildQuickLinksBlock(spec);
|
||||
case "notice_bar":
|
||||
return buildNoticeBarBlock(spec);
|
||||
default:
|
||||
return null; // 白名单外的脏数据跳过,不打挂渲染
|
||||
}
|
||||
@@ -463,6 +470,48 @@ public class PortalPageRenderer {
|
||||
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) {
|
||||
|
||||
@@ -44,9 +44,12 @@ public class SiteSaveRequest {
|
||||
private String configJson;
|
||||
|
||||
/**
|
||||
* 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key;
|
||||
* 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。
|
||||
* 主题微调参数(方案 §5.3)。
|
||||
*
|
||||
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
|
||||
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
|
||||
*/
|
||||
@Deprecated
|
||||
private Map<String, String> themeParams;
|
||||
|
||||
/** 0=停用 1=启用 */
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.cms.theme.ThemeParam;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */
|
||||
@Data
|
||||
@@ -16,4 +17,10 @@ public class ThemeMetaRequest {
|
||||
|
||||
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */
|
||||
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 java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 主题包 VO(管理端主题列表,方案 §5.2)。
|
||||
@@ -30,6 +31,12 @@ public class ThemeVo {
|
||||
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
|
||||
private List<ThemeParamVo> params;
|
||||
|
||||
/**
|
||||
* 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key;
|
||||
* 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。
|
||||
*/
|
||||
private Map<String, String> paramValues;
|
||||
|
||||
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
|
||||
private String source;
|
||||
|
||||
@@ -82,6 +89,8 @@ public class ThemeVo {
|
||||
for (ThemeParam p : info.getParams()) {
|
||||
vo.getParams().add(ThemeParamVo.of(p));
|
||||
}
|
||||
vo.setParamValues(info.getParamValues() == null
|
||||
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +289,12 @@ public class PortalQueryService {
|
||||
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) {
|
||||
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params");
|
||||
return raw instanceof Map ? (Map<?, ?>) raw : null;
|
||||
return themeRegistry.paramValuesOf(snapshot.getTheme());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +60,25 @@ public class SiteService {
|
||||
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
|
||||
private static final int CONFIG_VALUE_MAX_LEN = 500;
|
||||
|
||||
/** home_layout(docs/16 §4.6)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
|
||||
/** home_layout(docs/16 §4.6 + v2 手册 §2.8)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
|
||||
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
|
||||
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
|
||||
"article_list", "ticker", "gallery", "links")));
|
||||
"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_BLOCK_KEYS = 32;
|
||||
private static final int HOME_LAYOUT_MAX_TEXT = 100;
|
||||
private static final int HOME_LAYOUT_SIZE_MIN = 1;
|
||||
private static final int HOME_LAYOUT_SIZE_MAX = 20;
|
||||
/** 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):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
|
||||
private static final int CUSTOM_CSS_MAX_LEN = 10240;
|
||||
@@ -174,7 +184,9 @@ public class SiteService {
|
||||
}
|
||||
site.setTheme(resolvedTheme);
|
||||
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 = siteRepository.save(site);
|
||||
// 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 键;
|
||||
* 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 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
|
||||
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
|
||||
*/
|
||||
@@ -411,6 +401,11 @@ public class SiteService {
|
||||
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(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");
|
||||
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
|
||||
|| 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;黑名单忽略大小写拒绝
|
||||
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
|
||||
|
||||
@@ -36,6 +36,13 @@ public class ThemeInfo {
|
||||
|
||||
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() {
|
||||
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -306,6 +307,7 @@ public class ThemePackageService {
|
||||
root.put("description", description);
|
||||
}
|
||||
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
copyMetaParamValues(sourceName, newName);
|
||||
themeRegistry.reload();
|
||||
publishThemeChanged();
|
||||
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 遍历其全部资源 */
|
||||
private void copyClasspathPackage(String sourceName, Path target) throws IOException {
|
||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
@@ -354,9 +378,25 @@ public class ThemePackageService {
|
||||
/**
|
||||
* 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读,
|
||||
* 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。
|
||||
* 兼容签名:不改包级参数值。
|
||||
*/
|
||||
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
|
||||
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);
|
||||
if (!themeRegistry.contains(name)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
|
||||
@@ -365,6 +405,15 @@ public class ThemePackageService {
|
||||
if (!external && params != null) {
|
||||
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 {
|
||||
if (external) {
|
||||
Path json = Paths.get(themeDir, name, "theme.json");
|
||||
@@ -388,8 +437,11 @@ public class ThemePackageService {
|
||||
root.set("params", mapper.valueToTree(params));
|
||||
}
|
||||
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
if (filteredValues != null) {
|
||||
writeMetaOverride(name, null, null, filteredValues);
|
||||
}
|
||||
} else {
|
||||
writeBuiltinMetaOverride(name, title, description);
|
||||
writeMetaOverride(name, title, description, filteredValues);
|
||||
}
|
||||
themeRegistry.reload();
|
||||
publishThemeChanged();
|
||||
@@ -402,12 +454,16 @@ public class ThemePackageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置包元数据覆盖层写入(任务四):title/description 全空 = 删除覆盖层(恢复默认),
|
||||
* 否则合并写入 {themeDir}/.meta/{name}.json(title 空且 description 非空时保留已有 title)。
|
||||
* .meta 覆盖层写入:title/description/paramValues 合并进 {themeDir}/.meta/{name}.json。
|
||||
* 三者全空且未提交 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");
|
||||
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 {
|
||||
Files.deleteIfExists(overlay);
|
||||
} catch (IOException e) {
|
||||
@@ -433,6 +489,13 @@ public class ThemePackageService {
|
||||
if (description != null) {
|
||||
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.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
}
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
package com.yunhai.cms.theme;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用,
|
||||
* 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移:
|
||||
* specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、
|
||||
* fragments/head.html、param 规范、screenshot 存在。
|
||||
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1)。
|
||||
* fragments/head.html、首页区块引用一致性(§2.5 坑⑤)、param 规范、screenshot 存在。
|
||||
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1);
|
||||
* readFn 读资源文本(UTF-8),读不到返回 null(不臆断,交由存在性校验与模板引擎兜底)。
|
||||
*/
|
||||
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() {
|
||||
}
|
||||
|
||||
/** 校验不通过抛 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) {
|
||||
throw new IllegalArgumentException("清单为空");
|
||||
}
|
||||
@@ -38,6 +56,7 @@ public final class ThemePackageValidator {
|
||||
if (!existsFn.test("fragments/head.html")) {
|
||||
throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
|
||||
}
|
||||
validateHomeBlockFragmentRefs(dirName, readFn);
|
||||
validateParams(info);
|
||||
String shot = info.screenshotPath();
|
||||
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/<目录名>/ 字面两种写法),该文件就必须存在并定义被引用的
|
||||
* 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.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -113,8 +116,9 @@ public class ThemeRegistry {
|
||||
String dirName = dirNameOf(resource);
|
||||
try {
|
||||
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
|
||||
ThemePackageValidator.validate(info, dirName, rel ->
|
||||
new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists());
|
||||
ThemePackageValidator.validate(info, dirName,
|
||||
rel -> new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists(),
|
||||
rel -> readClasspath("templates/themes/" + dirName + "/" + rel));
|
||||
applyMetaOverride(info);
|
||||
loaded.put(info.getName(), info);
|
||||
loadedSources.put(info.getName(), SOURCE_BUILTIN);
|
||||
@@ -130,7 +134,9 @@ public class ThemeRegistry {
|
||||
Path json = pkg.resolve("theme.json");
|
||||
try {
|
||||
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())) {
|
||||
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
|
||||
continue;
|
||||
@@ -222,6 +228,24 @@ public class ThemeRegistry {
|
||||
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) */
|
||||
@@ -235,8 +259,8 @@ public class ThemeRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description——
|
||||
* 内置包 theme.json 在 jar 内只读,显示名/描述改名走此通道;损坏的覆盖层忽略(按原始元数据注册)。
|
||||
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
|
||||
* 内置包 theme.json 在 jar 内只读,显示名/描述改名与包级参数值均走此通道;损坏的覆盖层忽略(按原始元数据注册)。
|
||||
*/
|
||||
private void applyMetaOverride(ThemeInfo info) {
|
||||
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
|
||||
@@ -254,11 +278,26 @@ public class ThemeRegistry {
|
||||
if (meta.getDescription() != null) {
|
||||
info.setDescription(meta.getDescription());
|
||||
}
|
||||
if (meta.getParamValues() != null && !meta.getParamValues().isEmpty()) {
|
||||
info.setParamValues(new LinkedHashMap<>(meta.getParamValues()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
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;目录不存在视为空) */
|
||||
protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
|
||||
if (themeDir == null || themeDir.isEmpty()) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
样式随 fragment 内联携带——themes/common 不注册为皮肤包(无 theme.json),主题资产通道不服务该目录;
|
||||
颜色回落站点主题变量(--primary 等 themeVarsCss 注入),无变量时用内置缺省。
|
||||
契约: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">
|
||||
<style>
|
||||
@@ -19,6 +19,10 @@
|
||||
--plb-band-bg: var(--bg, #f5f7fb);
|
||||
--plb-border: var(--border, rgba(0, 0, 0, .08));
|
||||
--plb-radius: var(--radius, 10px);
|
||||
/* 公告条 warn 档:皮肤契约没有告警色变量,取骨架自持的暖色(不跟随皮肤) */
|
||||
--plb-warn-fg: #d46b08;
|
||||
--plb-warn-bg: #fff7e6;
|
||||
--plb-warn-border: #ffd591;
|
||||
}
|
||||
.plb-section{padding:26px 16px}
|
||||
.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 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-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 是三列)会把它们塞进单列,
|
||||
非网格父容器下 grid-column 无副作用,故由骨架自带。 */
|
||||
main > .plb-ticker{grid-column:1 / -1}
|
||||
main > .plb-newshot{grid-column:1 / -1}
|
||||
main > .plb-section{grid-column:1 / -1}
|
||||
main > .plb-notice-bar{grid-column:1 / -1}
|
||||
@media (max-width:768px){
|
||||
.plb-grid2{columns:1}
|
||||
.plb-imagewall{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-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="'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="'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:block>
|
||||
@@ -274,3 +293,36 @@ main > .plb-section{grid-column:1 / -1}
|
||||
</div>
|
||||
</div>
|
||||
</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-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>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- scholar-blue 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
|
||||
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 -->
|
||||
<header class="sp-header" th:fragment="header">
|
||||
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。
|
||||
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>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
|
||||
@@ -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>
|
||||
</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:replace="~{'themes/common/fragments/home-blocks' :: style}" />
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
|
||||
<th:block th: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>
|
||||
|
||||
|
||||
@@ -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": "bg", "cssVar": "--sp-bg", "label": "页面底色", "type": "color", "default": "#eff2f6" },
|
||||
{ "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")
|
||||
void missingTemplate_fallsBackToDefault() {
|
||||
ThemeRegistry registry = mock(ThemeRegistry.class);
|
||||
// 桩必须指向"确实不存在"的资源:scholar-blue 自 P-A1 起已备有 service.html,
|
||||
// 再用真实路径就让 .exists() 变真,用例守的"缺模板整页回退"语义就丢了。
|
||||
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);
|
||||
|
||||
assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html"))
|
||||
|
||||
@@ -169,6 +169,44 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
|
||||
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
|
||||
@DisplayName("非法 home_layout(blocks 非数组/type 白名单外):容错回落现状,门户不打挂")
|
||||
void invalidLayout_fallsBack() throws Exception {
|
||||
|
||||
@@ -118,12 +118,17 @@ class ThemeRenderIT extends CmsIntegrationTestBase {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T13: 微调参数注入 CSS 变量且位于皮肤 CSS link 之后")
|
||||
void themeParamsInjectedAfterSkinCss() throws Exception {
|
||||
persistSiteWithConfig("inj1", "{\"theme_params\":{\"primary\":\"#b01a2e\"}}");
|
||||
@DisplayName("T13(工作项 T 改写): 包级 paramValues 注入 CSS 变量且位于皮肤 CSS link 之后;站点 config_json.theme_params 残留值不再生效")
|
||||
void packageParamValuesInjectedAfterSkinCss() throws Exception {
|
||||
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");
|
||||
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 varIndex = html.indexOf("--primary:#b01a2e");
|
||||
assertThat(cssLinkIndex).as("皮肤 CSS link 应存在").isGreaterThan(-1);
|
||||
|
||||
@@ -34,6 +34,10 @@ class PortalQueryServiceCacheIT extends CmsIntegrationTestBase {
|
||||
private SiteService siteService;
|
||||
@Autowired
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
@Autowired
|
||||
private com.yunhai.cms.theme.ThemeRegistry themeRegistry;
|
||||
@Autowired
|
||||
private com.yunhai.cms.theme.ThemePackageService themePackageService;
|
||||
|
||||
private Integer siteId;
|
||||
private Integer categoryId;
|
||||
@@ -213,26 +217,37 @@ class PortalQueryServiceCacheIT extends CmsIntegrationTestBase {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T17: 改 theme_params 后门户立即生效(保存事件驱逐 cms:site,快照携带新值)")
|
||||
void siteSnapshot_themeParamsRefreshedAfterSave() {
|
||||
@DisplayName("T17: 改皮肤包级参数值后门户立即生效(保存事件驱逐 cms:site,快照携带新值)")
|
||||
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())
|
||||
.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<>();
|
||||
params.put("primary", "#b01a2e");
|
||||
req.setThemeParams(params);
|
||||
siteService.save(TestUsers.superAdmin(), req);
|
||||
themePackageService.updateMeta(TestUsers.superAdmin(),
|
||||
com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME, null, null, null, params);
|
||||
|
||||
PortalSiteSnapshot fresh = portalQueryService.siteSnapshot("demo1");
|
||||
assertThat(fresh.getThemeParams()).as("保存事件驱逐快照后应携带新参数")
|
||||
assertThat(fresh.getThemeParams()).as("包级参数写入事件驱逐快照后应携带新值")
|
||||
.containsEntry("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();
|
||||
}
|
||||
|
||||
@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) {
|
||||
SiteSaveRequest req = new SiteSaveRequest();
|
||||
req.setId(siteId);
|
||||
|
||||
@@ -268,12 +268,12 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T11: themeParams 服务端合并进 config_json.theme_params 且保留其余键")
|
||||
void save_themeParamsMergePreservesOtherConfigKeys() {
|
||||
@DisplayName("T11(工作项 T 改写): 参数值已迁皮肤包级——themeParams 入参被服务端忽略,不再写 config_json.theme_params,其余键保留")
|
||||
void save_themeParamsIgnoredOtherConfigKeysPreserved() {
|
||||
SiteSaveRequest req = new SiteSaveRequest();
|
||||
req.setCode("theme3");
|
||||
req.setSchId(1);
|
||||
req.setName("参数合并站");
|
||||
req.setName("参数退役站");
|
||||
req.setConfigJson("{\"logo\":\"/upload/cms/logo.png\",\"contact\":\"0571-88888888\"}");
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("primary", "#ff0000");
|
||||
@@ -283,16 +283,15 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
||||
|
||||
CmsSite saved = siteService.getByCode("theme3");
|
||||
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("contact", "0571-88888888");
|
||||
assertThat(config.get("theme_params")).isInstanceOf(Map.class);
|
||||
assertThat(((Map<?, ?>) config.get("theme_params")).get("primary"))
|
||||
.as("声明过的 key 应写入").isEqualTo("#ff0000");
|
||||
assertThat(saved.getConfigJson()).as("站点不再持有参数值(老前端仍提交该字段也不落库)")
|
||||
.doesNotContain("theme_params").doesNotContain("#ff0000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T12: themeParams 未声明 key 服务端丢弃,不落库")
|
||||
@DisplayName("T12: themeParams 未声明 key 服务端丢弃,不落库(工作项 T:站点级参数整体退役)")
|
||||
void save_undeclaredThemeParamKeyDropped() {
|
||||
SiteSaveRequest req = new SiteSaveRequest();
|
||||
req.setCode("theme4");
|
||||
@@ -305,9 +304,12 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
||||
siteService.save(TestUsers.superAdmin(), req);
|
||||
|
||||
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());
|
||||
assertThat(config).as("过滤后无有效参数 → 不写入 theme_params 键")
|
||||
assertThat(config).as("站点不再持有 theme_params 键")
|
||||
.doesNotContainKey("theme_params");
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,65 @@ class ThemePackageServiceTest {
|
||||
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 递归删除(测试临时目录专用) */
|
||||
private static void deleteRecursively(Path 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");
|
||||
}
|
||||
|
||||
// ===== 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)=====
|
||||
|
||||
/** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */
|
||||
@@ -308,6 +334,65 @@ class ThemeRegistryTest {
|
||||
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 递归删除(测试临时目录专用) */
|
||||
private static void deleteRecursively(java.nio.file.Path path) {
|
||||
if (!java.nio.file.Files.exists(path)) {
|
||||
|
||||
Reference in New Issue
Block a user