diff --git a/sql/upgrade_r1_school_code.sql b/sql/upgrade_r1_school_code.sql new file mode 100644 index 0000000..d4ad8de --- /dev/null +++ b/sql/upgrade_r1_school_code.sql @@ -0,0 +1,35 @@ +-- ============================================================ +-- R1 迁移脚本:学校主站编码改 5 位数字码(1 + 学校ID左补零至4位) +-- 例:sch_id=13 → code/path = 10013;幂等(只处理编码≠目标值的行) +-- 前置:W4 嵌套 path 机制已落地(cms_site.path 列已回填,uk_site_path 已建) +-- 旧链接提示:执行后 /portal/sch-13/ 不再解析(301 兼容仅覆盖子站按 code +-- 回查,主站不在其列);已对外发链需在 Caddy 层加旧前缀 301 或接受断链。 +-- ============================================================ + +-- 0) 前置校验:先人工看一眼存量主站 +-- ① 同一 sch_id 挂多个顶层站 → 改码后触发 uk_site_path 冲突,需先合并/停用; +-- ② sch_id 为空的顶层站(如演示站)会被本脚本跳过、保持原编码。 +SELECT id, code, sch_id, path, status FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL; + +-- 1) 顶层主站:code 与 path 一起改为目标 5 位码(sch_id 全局唯一 ⇒ 天然满足 +-- uk_site_path / uk_site_parent_code;sch_id 为空的顶层站跳过) +UPDATE cms_site + SET code = CONCAT('1', LPAD(sch_id, 4, '0')), + path = CONCAT('1', LPAD(sch_id, 4, '0')) + WHERE (parent_site_id = 0 OR parent_site_id IS NULL) + AND sch_id IS NOT NULL + AND code <> CONCAT('1', LPAD(sch_id, 4, '0')); + +-- 2) 子站 path 级联重算(code 不动,仅换父前缀;幂等) +UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id + SET s.path = CONCAT(p.path, '/', s.code) + WHERE s.parent_site_id > 0 + AND s.path <> CONCAT(p.path, '/', s.code); + +-- 3) 校验(两条都应返回 0 行) +SELECT id, code, path FROM cms_site WHERE path = '' OR path IS NULL; +SELECT id, code, path FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL; + +-- 4) 收尾:缓存为进程内 Caffeine(站点快照 TTL 30min、旧 key 自然过期); +-- 想立即生效可在站点管理保存一次任一站点(触发 SiteContentChangedEvent 清缓存), +-- 或重启 cms 容器。 diff --git a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java index 0acd80f..7be2901 100644 --- a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java +++ b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java @@ -48,27 +48,34 @@ public class ThemeController { return themeService.list(); } - /** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册 */ + /** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册。 + * 任务四:title/description 选填,落位前改写包内 theme.json(留空沿用包内声明) */ @PostMapping("/upload") - public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) { - ThemeInfo info = themePackageService.upload(authUserWithDept, file); + public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "description", required = false) String description) { + ThemeInfo info = themePackageService.upload(authUserWithDept, file, title, description); return themeService.voOf(info); } - /** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除) */ + /** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除);任务四支持同步命名 */ @PostMapping("/derive") public com.yunhai.cms.model.vo.ThemeVo derive(BoaUser authUserWithDept, @RequestBody com.yunhai.cms.model.dto.ThemeDeriveRequest req) { - ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName()); + ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName(), + req.getTitle(), req.getDescription()); return themeService.voOf(info); } - /** W1(G4):编辑外部包 theme.json 的 title 与参数 schema(保存后引用站点立即生效) */ + /** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema), + * 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效 */ @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.getParams()); + ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name, + req.getTitle(), req.getDescription(), req.getParams()); return themeService.voOf(info); } diff --git a/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java b/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java index 5e35a6f..bbe21b6 100644 --- a/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java +++ b/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java @@ -5,7 +5,7 @@ import lombok.Data; import java.util.List; -/** W1 皮肤包派生请求(POST /api/theme/derive) */ +/** W1 皮肤包派生请求(POST /api/theme/derive);任务四增选填 title/description(写入新包 theme.json) */ @Data public class ThemeDeriveRequest { @@ -14,4 +14,10 @@ public class ThemeDeriveRequest { /** 新包名(外部包目录名,theme.json name 同步改写) */ private String name; + + /** 显示名称(选填;留空沿用源包 title) */ + private String title; + + /** 描述(选填;null=沿用源包,""=清空) */ + private String description; } diff --git a/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java b/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java index 57a510a..40bff9f 100644 --- a/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java +++ b/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java @@ -5,12 +5,15 @@ import lombok.Data; import java.util.List; -/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta):title/参数 schema,保留其余 theme.json 字段 */ +/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */ @Data public class ThemeMetaRequest { private String title; - /** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改 */ + /** 描述(null=不改;""=清空) */ + private String description; + + /** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */ private List params; } diff --git a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java index b0ec6d2..1356688 100644 --- a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java +++ b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java @@ -75,7 +75,7 @@ public class PortalQueryService { return null; } PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site); - snapshot.setTheme(resolveTheme(snapshot.getTheme())); + snapshot.setTheme(resolveTheme(snapshot.getTheme(), snapshot.getCode())); derivePublicBaseUrl(site, snapshot); // 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致 com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme()); @@ -279,11 +279,11 @@ public class PortalQueryService { } /** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */ - private String resolveTheme(String theme) { + private String resolveTheme(String theme, String siteCode) { if (themeRegistry.contains(theme)) { return theme; } - log.warn("皮肤包 {} 未注册,回退 default", theme); + log.warn("皮肤包 {} 未注册(site={}),回退 default", theme, siteCode); return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME; } diff --git a/src/main/java/com/yunhai/cms/service/impl/ThemeService.java b/src/main/java/com/yunhai/cms/service/impl/ThemeService.java index 7cf2179..491dce9 100644 --- a/src/main/java/com/yunhai/cms/service/impl/ThemeService.java +++ b/src/main/java/com/yunhai/cms/service/impl/ThemeService.java @@ -43,7 +43,8 @@ public class ThemeService { usedNames.add(s.getName()); } vo.setUsedBySiteNames(usedNames); - vo.setEditable(ThemeRegistry.SOURCE_EXTERNAL.equals(source)); + // 任务四:语义改为「可编辑元数据」——内置包亦可改显示名/描述(.meta 覆盖层),内容仍只读 + vo.setEditable(true); return vo; } diff --git a/src/main/java/com/yunhai/cms/theme/ThemePackageService.java b/src/main/java/com/yunhai/cms/theme/ThemePackageService.java index e426b17..c980557 100644 --- a/src/main/java/com/yunhai/cms/theme/ThemePackageService.java +++ b/src/main/java/com/yunhai/cms/theme/ThemePackageService.java @@ -74,7 +74,7 @@ public class ThemePackageService { // ===== 上传(T-W1-05..12)===== - public ThemeInfo upload(BoaUser user, MultipartFile file) { + public ThemeInfo upload(BoaUser user, MultipartFile file, String title, String description) { requireSuperAdmin(user); if (file == null || file.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空"); @@ -108,6 +108,8 @@ public class ThemePackageService { if (!report.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:" + String.join(";", report)); } + // 上传表单命名(任务四):落位前改写 staging 内 theme.json,失败即整体失败不落位 + applyStagedNaming(rootDir == null ? staging : staging.resolve(rootDir), title, description); // 原子替换/落位(同名外部包=覆盖更新,T-W1-12) Path target = Paths.get(themeDir, pkgName); if (Files.exists(target)) { @@ -122,7 +124,7 @@ public class ThemePackageService { } catch (BizException e) { throw e; } catch (Exception e) { - throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包处理失败:" + e.getMessage()); + throw wrapFailure("上传", "皮肤包处理失败:", e); } finally { if (staging != null) { deleteRecursively(staging); @@ -142,9 +144,26 @@ public class ThemePackageService { return null; } + /** 上传表单命名(任务四):落位前改写 staging 内 theme.json 的 title/description(空值跳过对应字段) */ + private void applyStagedNaming(Path pkgDir, String title, String description) throws IOException { + if (!StringUtils.hasText(title) && description == null) { + return; + } + Path json = pkgDir.resolve("theme.json"); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode root = + (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile()); + if (StringUtils.hasText(title)) { + root.put("title", title.trim()); + } + if (description != null) { + root.put("description", description); + } + Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); + } + /** 解包到 staging;返回 theme.json 声明的 name(防御:zip-slip/条目数/体积/扩展名白名单) */ - private String extractZip(MultipartFile file, Path staging, List report) throws IOException { - String declaredName = null; + private String extractZip(MultipartFile file, Path staging, List report) throws IOException { String declaredName = null; int entries = 0; long total = 0; try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) { @@ -198,6 +217,7 @@ public class ThemePackageService { declaredName = new com.fasterxml.jackson.databind.ObjectMapper() .readTree(json).path("name").asText(null); } catch (Exception e) { + log.warn("zip 内 theme.json 解析失败,忽略声明包名:{}", e.getMessage()); declaredName = null; } } @@ -253,7 +273,7 @@ public class ThemePackageService { // ===== 派生(T-W1-17)===== - public ThemeInfo derive(BoaUser user, String sourceName, String newName) { + public ThemeInfo derive(BoaUser user, String sourceName, String newName, String title, String description) { requireSuperAdmin(user); if (!themeRegistry.contains(sourceName)) { throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + sourceName); @@ -272,12 +292,20 @@ public class ThemePackageService { } else { copyClasspathPackage(sourceName, target); } - // 改写 theme.json:name=newName(title 追加副名,其余保留) + // 改写 theme.json(任务四「添加皮肤包」表单):name=newName;显示名/描述按表单覆盖。 + // ObjectNode 写回取代旧 regex replaceAll——源包名出现在其他字段值(如描述)时不再误替换 Path json = target.resolve("theme.json"); - String content = new String(Files.readAllBytes(json), java.nio.charset.StandardCharsets.UTF_8); - content = content.replaceAll("(\"name\"\\s*:\\s*\")" + sourceName + "(\")", - "$1" + newName + "$2"); - Files.write(json, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode root = + (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile()); + root.put("name", newName); + if (StringUtils.hasText(title)) { + root.put("title", title.trim()); + } + if (description != null) { + root.put("description", description); + } + Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); themeRegistry.reload(); publishThemeChanged(); log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName); @@ -286,7 +314,7 @@ public class ThemePackageService { throw e; } catch (Exception e) { deleteRecursively(target); - throw new BizException(ErrorCode.PARAM_ERROR, "派生皮肤包失败:" + e.getMessage()); + throw wrapFailure("派生", "派生皮肤包失败:", e); } } @@ -321,46 +349,94 @@ public class ThemePackageService { } } - // ===== 编辑(T-W1-17:title/参数 schema;保留 specVersion/模板清单字段)===== + // ===== 编辑(T-W1-17 + 任务四:title/描述/参数 schema;保留 specVersion/模板清单字段)===== - public ThemeInfo updateMeta(BoaUser user, String name, String title, List params) { + /** + * 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读, + * 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。 + */ + public ThemeInfo updateMeta(BoaUser user, String name, String title, String description, + List params) { requireSuperAdmin(user); if (!themeRegistry.contains(name)) { throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name); } - if (!ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name))) { - throw new BizException(ErrorCode.FORBIDDEN, "内置包不可编辑(D1:内置只读)"); + boolean external = ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name)); + if (!external && params != null) { + throw new BizException(ErrorCode.PARAM_ERROR, "内置包参数 schema 不可修改(仅显示名称/描述可改)"); } - Path json = Paths.get(themeDir, name, "theme.json"); try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - com.fasterxml.jackson.databind.node.ObjectNode root = - (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile()); - if (StringUtils.hasText(title)) { - root.put("title", title); - } - if (params != null) { - ThemeInfo probe = new ThemeInfo(); - probe.setParams(params); - try { - ThemePackageValidator.validateParams(probe); - } catch (IllegalArgumentException e) { - throw new BizException(ErrorCode.PARAM_ERROR, "参数 schema 非法:" + e.getMessage()); + if (external) { + Path json = Paths.get(themeDir, name, "theme.json"); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode root = + (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile()); + if (StringUtils.hasText(title)) { + root.put("title", title); } - root.set("params", mapper.valueToTree(params)); + if (description != null) { + root.put("description", description); + } + if (params != null) { + ThemeInfo probe = new ThemeInfo(); + probe.setParams(params); + try { + ThemePackageValidator.validateParams(probe); + } catch (IllegalArgumentException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数 schema 非法:" + e.getMessage()); + } + root.set("params", mapper.valueToTree(params)); + } + Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); + } else { + writeBuiltinMetaOverride(name, title, description); } - Files.write(json, mapper.writerWithDefaultPrettyPrinter() - .writeValueAsBytes(root)); themeRegistry.reload(); publishThemeChanged(); return themeRegistry.get(name); } catch (BizException e) { throw e; } catch (IOException e) { - throw new BizException(ErrorCode.PARAM_ERROR, "theme.json 写入失败:" + e.getMessage()); + throw wrapFailure("元数据写入", "theme.json 写入失败:", e); } } + /** + * 内置包元数据覆盖层写入(任务四):title/description 全空 = 删除覆盖层(恢复默认), + * 否则合并写入 {themeDir}/.meta/{name}.json(title 空且 description 非空时保留已有 title)。 + */ + private void writeBuiltinMetaOverride(String name, String title, String description) throws IOException { + Path overlay = Paths.get(themeDir, ".meta", name + ".json"); + if (!StringUtils.hasText(title) && (description == null || description.trim().isEmpty())) { + try { + Files.deleteIfExists(overlay); + } catch (IOException e) { + log.warn("元数据覆盖层 {} 删除失败:{}", overlay, e.getMessage()); + } + return; + } + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode root; + if (Files.exists(overlay)) { + try { + root = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(overlay.toFile()); + } catch (Exception e) { + log.warn("元数据覆盖层 {} 损坏,重建:{}", overlay, e.getMessage()); + root = mapper.createObjectNode(); + } + } else { + root = mapper.createObjectNode(); + } + if (StringUtils.hasText(title)) { + root.put("title", title.trim()); + } + if (description != null) { + root.put("description", description); + } + Files.createDirectories(overlay.getParent()); + Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root)); + } + // ===== 删除(T-W1-15/16)===== public void remove(BoaUser user, String name) { @@ -381,6 +457,8 @@ public class ThemePackageService { "皮肤包正被站点使用,无法删除:" + String.join("、", names)); } deleteRecursively(Paths.get(themeDir, name)); + // 元数据覆盖层一并清理(任务四;外部包通常没有,best-effort) + deleteRecursively(Paths.get(themeDir, ".meta", name + ".json")); themeRegistry.reload(); publishThemeChanged(); log.info("皮肤包 {} 已删除并热注销", name); @@ -395,6 +473,16 @@ public class ThemePackageService { // ===== 内部 ===== + /** + * 兜底异常包装(C 级修复):先以 error 级记录原始堆栈再转业务异常—— + * 线上(/app 容器)只能看到接口文案,没有这一步则 FileSystemException 等 + * 只剩裸路径无从排查。userMessage 保持既有文案格式(含冒号后缀)。 + */ + static BizException wrapFailure(String logAction, String userMessage, Exception e) { + log.error("皮肤包{}失败", logAction, e); + return new BizException(ErrorCode.PARAM_ERROR, userMessage + e.getMessage()); + } + private void requireSuperAdmin(BoaUser user) { if (user == null || user.getType() == null || user.getType() != 1) { throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作"); @@ -452,10 +540,12 @@ public class ThemePackageService { walk.sorted(Comparator.reverseOrder()).forEach(p -> { try { Files.delete(p); - } catch (IOException ignored) { + } catch (IOException e) { + log.debug("清理失败(best-effort):{}", p, e); } }); - } catch (IOException ignored) { + } catch (IOException e) { + log.debug("清理失败(best-effort):{}", path, e); } } } diff --git a/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java b/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java index 9760d7f..e0e9ef5 100644 --- a/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java +++ b/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java @@ -50,6 +50,9 @@ public class ThemeRegistry { private static final String SCAN_PATTERN = "classpath*:templates/themes/*/theme.json"; + /** 元数据覆盖层目录(任务四):{cms.theme.dir}/.meta/{name}.json;外部包扫描按「含 theme.json 的目录」过滤,天然跳过 */ + private static final String META_OVERRIDE_DIR = ".meta"; + /** 未知字段忽略、已知字段兼容(方案 §4.2 specVersion 演进策略) */ private static final ObjectMapper MAPPER = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -112,6 +115,7 @@ public class ThemeRegistry { ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class); ThemePackageValidator.validate(info, dirName, rel -> new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists()); + applyMetaOverride(info); loaded.put(info.getName(), info); loadedSources.put(info.getName(), SOURCE_BUILTIN); } catch (Exception e) { @@ -131,6 +135,7 @@ public class ThemeRegistry { log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName); continue; } + applyMetaOverride(info); loaded.put(info.getName(), info); loadedSources.put(info.getName(), SOURCE_EXTERNAL); } catch (Exception e) { @@ -229,9 +234,33 @@ public class ThemeRegistry { } } + /** + * 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description—— + * 内置包 theme.json 在 jar 内只读,显示名/描述改名走此通道;损坏的覆盖层忽略(按原始元数据注册)。 + */ + private void applyMetaOverride(ThemeInfo info) { + if (themeDir == null || themeDir.isEmpty() || info.getName() == null) { + return; + } + Path overrideFile = Paths.get(themeDir, META_OVERRIDE_DIR, info.getName() + ".json"); + if (!Files.isRegularFile(overrideFile)) { + return; + } + try { + ThemeInfo meta = MAPPER.readValue(overrideFile.toFile(), ThemeInfo.class); + if (meta.getTitle() != null && !meta.getTitle().trim().isEmpty()) { + info.setTitle(meta.getTitle().trim()); + } + if (meta.getDescription() != null) { + info.setDescription(meta.getDescription()); + } + } catch (Exception e) { + log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage()); + } + } + /** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */ - protected List scanExternalPackages() { - List packages = new ArrayList<>(); + protected List scanExternalPackages() { List packages = new ArrayList<>(); if (themeDir == null || themeDir.isEmpty()) { return packages; } diff --git a/src/main/resources/templates/themes/fresh-mint2/README.md b/src/main/resources/templates/themes/fresh-mint2/README.md new file mode 100644 index 0000000..aee78a9 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/README.md @@ -0,0 +1,36 @@ +# fresh-mint2 皮肤包 + +由 blue-white 派生的清新薄荷风皮肤(**卡片封面版**,docs/10 契约 §5 工作流)。**升级随版本发布,不在站点间复制目录**。 + +## 风格定位 + +清爽、亲和,适合职业院校门户:主色薄荷青、圆角 12px、卡片纯白+柔和阴影、细线吸顶顶栏+居中 logo、列表卡片网格(一行 3 张)支持封面图、几何圆点装饰与大留白;无纹理、无通栏大图、无复杂动效。 + +与 `fresh-mint`(同色系基础版)的差异:列表为带封面的卡片网格(`listLayout=grid`,`decoration=dots`),header 为 3 列网格(左:专业院系 / 中:品牌 / 右:搜索);`fresh-mint` 采用 top-bar + brand-row 顶栏与纯文字列表卡片。 + +## 数据契约(模板可取用的 model 属性全集,契约 §3) + +| 页面 | 属性 | +| --- | --- | +| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/upload/asset/search/siteHome)、`themeBase`(=`themes/fresh-mint2`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) | +| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) | +| list / search | `listData`(分页 + `items[].coverImage`)、`category`(list)、`q` + `searchMode`(search) | +| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` | +| single | `category`(含 `content`)、`currentCategoryId` | + +原则:皮肤不得假设"首页一定有轮播"、"列表一定有封面",对空集合优雅降级。 + +## 轮播图尺寸约定(bannerSize) + +1920x560(管理端上传端按此引导裁剪)。 + +## 可调参数 + +见 `theme.json` 的 `params`: + +- **color**(进 CSS 变量):`primary` 主色 / `primaryDark` 主色加深(用于品牌文字、悬停加深)/ `primarySoft` 主色浅调(装饰、点缀背景)/ `bg` 页面底色 / `border` 边框色。 +- **select**:`listLayout` = `grid`(默认,卡片网格一行 3 张,支持封面)或 `list`(简约列表,封面靠左缩略);`decoration` = `dots`(默认,背景几何圆点)或 `plain`(纯净无装饰)。 + +换色建议:主色系三值同步调整(primary / primaryDark / primarySoft),bg 与 border、primarySoft 保持同族浅色调,避免底色与卡片对比失衡。 + +优先级:`custom_css` > `themeVarsCss` > `main.css`。 diff --git a/src/main/resources/templates/themes/fresh-mint2/assets/css/main.css b/src/main/resources/templates/themes/fresh-mint2/assets/css/main.css new file mode 100644 index 0000000..dead9df --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/assets/css/main.css @@ -0,0 +1,624 @@ +/* ============================================================ + * fresh-mint2 皮肤包样式(blue-white 派生,docs/10 皮肤包契约 §5) + * 清新薄荷风(卡片封面版):主色 #17A2A2、浅薄荷底、白卡 12px 圆角 + 柔和阴影; + * 细线吸顶顶栏 + 居中 logo;列表为带封面的卡片网格(一行 3 张);几何圆点装饰。 + * 无纹理、无通栏大图、无复杂动效。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪)。 + * ============================================================ */ +:root { + --primary: #17A2A2; + --primary-dark: #0F7C7C; + --primary-soft: #E6F6F6; + --bg: #F5FBFA; + --card-bg: #FFFFFF; + --border: #DCEDEB; + --text: #1F2D2D; + --text-light: #6B8382; + + --radius: 12px; + --radius-sm: 8px; + --max-width: 1200px; + + --shadow-card: 0 6px 18px rgba(15, 124, 124, .06); + --shadow-hover: 0 14px 30px rgba(15, 124, 124, .13); + --shadow-pop: 0 10px 26px rgba(15, 124, 124, .12); +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.7; + color: var(--text); + background-color: var(--bg); + background-image: radial-gradient(rgba(23, 162, 162, .10) 1.6px, transparent 1.6px); + background-size: 28px 28px; + background-attachment: fixed; +} +body.decor-plain { background-image: none; } +h1, h2, h3 { font-weight: 600; letter-spacing: .01em; } +a { color: inherit; text-decoration: none; } +a:hover { color: var(--primary-dark); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { + color: var(--text-light); + text-align: center; + padding: 36px 0; + background: var(--card-bg); + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +/* ===== 头部:细线顶栏 + 居中 logo + 吸顶 ===== */ +.site-header { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255, 255, 255, .94); + border-bottom: 1px solid var(--border); + box-shadow: 0 2px 12px rgba(15, 124, 124, .05); +} +.header-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 12px 16px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; +} +.header-aside { display: flex; align-items: center; gap: 10px; min-width: 0; } +.header-aside-left { justify-self: start; } +.header-aside-right { justify-self: end; } + +.site-brand { display: flex; align-items: center; gap: 12px; justify-self: center; } +.site-logo { height: 40px; width: auto; } +.site-name { + font-size: 22px; + font-weight: 600; + color: var(--primary-dark); + letter-spacing: .02em; + white-space: nowrap; +} + +.site-search { display: flex; gap: 8px; } +.site-search input { + border: 1px solid var(--border); + background: var(--primary-soft); + border-radius: 999px; + padding: 8px 16px; + width: 190px; + font-size: 14px; + color: var(--text); + outline: none; +} +.site-search input::placeholder { color: #9bb6b5; } +.site-search input:focus { border-color: var(--primary); background: #fff; } +.site-search button { + border: none; + border-radius: 999px; + background: var(--primary); + color: #fff; + font-size: 14px; + padding: 8px 20px; + cursor: pointer; +} +.site-search button:hover { background: var(--primary-dark); color: #fff; } + +/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: #fff; + padding: 7px 16px; + cursor: pointer; + color: var(--primary-dark); + font-size: 14px; + font-weight: 500; + white-space: nowrap; +} +.site-colleges .colleges-toggle:hover { border-color: var(--primary); background: var(--primary-soft); } +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 6px; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-pop); + z-index: 210; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + padding: 9px 14px; + border-radius: var(--radius-sm); + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: var(--primary-soft); color: var(--primary-dark); } +.site-colleges .colleges-menu li.active a { color: var(--primary-dark); font-weight: 600; background: var(--primary-soft); } + +/* ===== 主导航:细线文字导航 + 薄荷下划线激活态 ===== */ +.site-nav { background: transparent; border-top: 1px solid var(--border); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + flex-wrap: wrap; + justify-content: center; +} +.site-nav > ul > li { position: relative; } +.site-nav li > a { + display: block; + padding: 13px 22px; + color: var(--text); + font-weight: 500; + position: relative; +} +.site-nav > ul > li > a::before { + content: ""; + position: absolute; + left: 22px; + right: 22px; + bottom: 8px; + height: 2px; + border-radius: 2px; + background: var(--primary); + transform: scaleX(0); + transition: transform .18s ease; +} +.site-nav > ul > li:hover > a::before, +.site-nav > ul > li.active > a::before { transform: scaleX(1); } +.site-nav > ul > li:hover > a, +.site-nav > ul > li.active > a { color: var(--primary-dark); } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .7; } + +/* ===== 二级下拉导航 ===== */ +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 178px; + max-width: none; + margin: 0; + padding: 6px; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-pop); + z-index: 205; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + padding: 9px 16px; + border-radius: var(--radius-sm); + color: var(--text); + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a::before { display: none; } +.site-nav .sub-nav li a:hover { background: var(--primary-soft); color: var(--primary-dark); } +.site-nav .sub-nav li.active a { color: var(--primary-dark); font-weight: 600; } + +/* ===== 首页轮播(收窄 + 圆角,不做通栏大图) ===== */ +.banner { + position: relative; + max-width: var(--max-width); + margin: 24px auto 4px; + padding: 0 16px; +} +.banner-slides { + position: relative; + height: 380px; + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow-card); +} +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 16px; + right: auto; + bottom: 16px; + max-width: 68%; + padding: 8px 18px; + border-radius: 999px; + background: rgba(255, 255, 255, .92); + color: var(--text); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.banner-dots { + position: absolute; + bottom: 24px; + right: 32px; + display: flex; + gap: 8px; +} +.banner-dots button { + width: 9px; + height: 9px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .75); + cursor: pointer; +} +.banner-dots button.active { background: var(--primary); transform: scale(1.25); } + +/* ===== 通用区块:白卡 + 柔和阴影 ===== */ +.top-strip, .category-block { + max-width: var(--max-width); + margin: 20px auto; + padding: 22px 24px 24px; + background: var(--card-bg); + border: none; + border-radius: var(--radius); + box-shadow: var(--shadow-card); +} +.section-title, .block-head h2 { + margin: 0 0 16px; + font-size: 19px; + font-weight: 600; + display: flex; + align-items: center; +} +.section-title::before, .block-head h2::before { + content: ""; + flex: none; + width: 7px; + height: 7px; + margin-right: 26px; + border-radius: 50%; + background: var(--primary); + box-shadow: 12px 0 0 rgba(23, 162, 162, .32), 24px 0 0 rgba(23, 162, 162, .14); +} +.block-head { display: flex; align-items: baseline; justify-content: space-between; } +.more { + font-size: 13px; + color: var(--primary-dark); + background: var(--primary-soft); + border-radius: 999px; + padding: 4px 14px; + flex: none; +} + +/* 头条推荐:卡片网格(listLayout=list 时回落为行列表) */ +.top-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.top-grid li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + background: var(--primary-soft); + border-radius: var(--radius-sm); +} +.top-grid li a { + flex: 1; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-grid li time { flex: none; } +.top-grid.is-list { display: block; } +.top-grid.is-list li { background: transparent; padding: 11px 0; border-bottom: 1px solid var(--border); } +.top-grid.is-list li:last-child { border-bottom: none; } + +/* 栏目区块:行列表 + 圆点标记 */ +.block-list { list-style: none; margin: 0; padding: 0; } +.block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid var(--border); +} +.block-list li:last-child { border-bottom: none; } +.block-list li a { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.block-list li a::before { + content: ""; + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--primary); + opacity: .5; +} +.top-badge { + flex: none; + color: #fff; + background: var(--primary); + font-size: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: 999px; +} + +/* ===== 列表页:卡片网格(一行三张,支持封面) ===== */ +.list-main { + max-width: var(--max-width); + margin: 0 auto; + padding: 24px 16px 48px; + background: transparent; + border: none; +} +.home-main { padding-bottom: 8px; } +.breadcrumb { color: var(--text-light); font-size: 13px; margin: 0 0 14px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; color: var(--primary-dark); } +.page-title { + font-size: 24px; + font-weight: 600; + margin: 0 0 22px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} + +.article-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} +.article-list li { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; + transition: transform .2s ease, box-shadow .2s ease; +} +.article-list li:hover { transform: translateY(-4px); box-shadow: var(--shadow-hover); } +.item-card { display: flex; flex-direction: column; height: 100%; } +.item-cover { + display: block; + aspect-ratio: 16 / 9; + overflow: hidden; + background: var(--primary-soft); +} +.item-cover img { display: block; width: 100%; height: 100%; object-fit: cover; } +.item-body { display: flex; flex-direction: column; gap: 8px; padding: 16px 18px 18px; flex: 1; } +.item-title { + font-size: 16px; + font-weight: 600; + line-height: 1.5; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.item-summary { + color: var(--text-light); + font-size: 14px; + line-height: 1.7; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.item-meta { display: flex; gap: 10px; align-items: center; margin-top: auto; padding-top: 4px; } + +.article-list.is-list { display: block; } +.article-list.is-list li { margin-bottom: 14px; } +.article-list.is-list .item-card { flex-direction: row; align-items: center; gap: 18px; padding: 16px 20px; } +.article-list.is-list .item-cover { width: 190px; flex: none; aspect-ratio: 16 / 10; border-radius: var(--radius-sm); } +.article-list.is-list .item-body { padding: 0; } +.article-list.is-list .item-meta { margin-top: 4px; } + +/* ===== 分页 ===== */ +.pagination { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; padding: 36px 0 8px; } +.pagination a { + min-width: 36px; + text-align: center; + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card-bg); + color: var(--text-light); +} +.pagination a:hover { border-color: var(--primary); color: var(--primary-dark); } +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情 / 单页 ===== */ +.detail-main { + max-width: 1000px; + margin: 24px auto 48px; + padding: 36px 40px 40px; + background: var(--card-bg); + border: none; + border-radius: var(--radius); + box-shadow: var(--shadow-card); +} +.article-title { margin: 8px 0 14px; font-size: 27px; font-weight: 600; text-align: center; line-height: 1.45; } +.article-meta { + display: flex; + gap: 0; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} +.article-meta > * { padding: 0 12px; position: relative; } +.article-meta > * + *::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 4px; + height: 4px; + margin-top: -2px; + border-radius: 50%; + background: var(--primary); + opacity: .5; +} +.article-content { line-height: 1.95; font-size: 16px; padding-top: 24px; word-break: break-word; color: var(--text); } +.article-content p { margin: 0 0 16px; } +.article-content h1, .article-content h2, .article-content h3 { margin: 26px 0 12px; } +.article-content img { height: auto; border-radius: var(--radius-sm); } +.article-content a { color: var(--primary-dark); text-decoration: underline; text-underline-offset: 3px; } +.article-content blockquote { + margin: 18px 0; + padding: 12px 18px; + background: var(--primary-soft); + border-left: 3px solid var(--primary); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 8px 12px; } +.article-sibling { display: flex; flex-direction: column; gap: 10px; margin-top: 32px; font-size: 14px; } +.article-sibling a { + padding: 12px 18px; + border-radius: var(--radius-sm); + background: var(--card-bg); + border: 1px solid var(--border); + color: var(--text-light); +} +.article-sibling a:hover { border-color: var(--primary); color: var(--primary-dark); } + +/* ===== 页脚(浅薄荷底) ===== */ +.site-footer { + margin-top: 40px; + background: var(--primary-soft); + color: var(--text-light); + border-top: 1px solid var(--border); +} +.footer-decor { + height: 26px; + display: flex; + justify-content: center; + align-items: center; +} +.footer-decor::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--primary); + box-shadow: 14px 0 0 rgba(23, 162, 162, .32), -14px 0 0 rgba(23, 162, 162, .32); +} +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 8px 16px 4px; + display: flex; + flex-direction: column; + gap: 10px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; font-size: 13px; } +.group-name { + font-weight: 600; + color: var(--primary-dark); + margin-right: 6px; + padding-right: 16px; + position: relative; +} +.group-name::after { + content: ""; + position: absolute; + right: 0; + top: 50%; + width: 4px; + height: 4px; + margin-top: -2px; + border-radius: 50%; + background: var(--primary); + opacity: .5; +} +.links-group a { color: var(--text-light); } +.links-group a:hover { color: var(--primary-dark); } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 14px 16px 26px; + text-align: center; + font-size: 13px; +} +.footer-info p { margin: 4px 0; } +.links-group a .link-logo, +.sp-links-row a .link-logo { + height: 20px; + width: auto; + max-width: 64px; + object-fit: contain; + vertical-align: middle; + margin-right: 4px; + border-radius: 4px; +} + +/* ===== 移动端适配 ===== */ +@media (max-width: 768px) { + .header-inner { grid-template-columns: 1fr; justify-items: center; gap: 12px; padding: 12px 14px; } + .site-brand { order: 1; justify-self: center; } + .header-aside-right { order: 2; justify-self: stretch; } + .header-aside-left { order: 3; justify-self: center; } + .site-search { flex: 1; } + .site-search input { flex: 1; width: auto; min-width: 0; } + .site-colleges .colleges-menu { left: 50%; right: auto; transform: translateX(-50%); min-width: 200px; } + .site-nav ul { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start; } + .site-nav > ul > li > a { padding: 12px 16px; white-space: nowrap; } + .site-nav > ul > li > a::before { left: 16px; right: 16px; } + + .banner { margin-top: 16px; } + .banner-slides { height: 190px; } + .banner-title { font-size: 13px; padding: 6px 14px; } + .banner-dots { bottom: 14px; right: 20px; } + + .top-grid { grid-template-columns: 1fr; } + .article-list { grid-template-columns: 1fr; } + .article-list.is-list .item-card { flex-direction: column; align-items: stretch; } + .article-list.is-list .item-cover { width: 100%; aspect-ratio: 16 / 9; } + .top-strip, .category-block { padding: 18px 16px 20px; margin: 16px auto; } + .detail-main { margin: 16px auto 32px; padding: 22px 18px 26px; } + .article-title { font-size: 22px; } + .article-content { font-size: 15px; } + + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + border-radius: var(--radius-sm); + box-shadow: none; + background: var(--primary-soft); + min-width: 0; + padding: 6px; + } + .site-nav .sub-nav li a { color: var(--text); padding: 8px 14px; } +} diff --git a/src/main/resources/templates/themes/fresh-mint2/assets/favicon.png b/src/main/resources/templates/themes/fresh-mint2/assets/favicon.png new file mode 100644 index 0000000..497cae1 Binary files /dev/null and b/src/main/resources/templates/themes/fresh-mint2/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/fresh-mint2/assets/favicon.svg b/src/main/resources/templates/themes/fresh-mint2/assets/favicon.svg new file mode 100644 index 0000000..cbfc6c6 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/themes/fresh-mint2/assets/js/main.js b/src/main/resources/templates/themes/fresh-mint2/assets/js/main.js new file mode 100644 index 0000000..d1918c6 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/assets/js/main.js @@ -0,0 +1,40 @@ +/* fresh-mint2 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */ +(function () { + var slides = document.querySelectorAll('.banner-slide'); + var dots = document.querySelectorAll('.banner-dots button'); + if (!slides.length) { + return; + } + var current = 0; + var timer = null; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, 5000); + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + window.clearInterval(timer); + show(index); + start(); + }); + }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/fresh-mint2/assets/screenshot.png b/src/main/resources/templates/themes/fresh-mint2/assets/screenshot.png new file mode 100644 index 0000000..534893b Binary files /dev/null and b/src/main/resources/templates/themes/fresh-mint2/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/fresh-mint2/detail.html b/src/main/resources/templates/themes/fresh-mint2/detail.html new file mode 100644 index 0000000..f59c393 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/main/resources/templates/themes/fresh-mint2/fragments/footer.html b/src/main/resources/templates/themes/fresh-mint2/fragments/footer.html new file mode 100644 index 0000000..b0e7a5e --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/fragments/footer.html @@ -0,0 +1,23 @@ + + + +
+ + + + +
+ diff --git a/src/main/resources/templates/themes/fresh-mint2/fragments/head.html b/src/main/resources/templates/themes/fresh-mint2/fragments/head.html new file mode 100644 index 0000000..c552917 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/fragments/head.html @@ -0,0 +1,25 @@ + + + + + + + + 页面标题 + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/themes/fresh-mint2/fragments/header.html b/src/main/resources/templates/themes/fresh-mint2/fragments/header.html new file mode 100644 index 0000000..e727949 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/fragments/header.html @@ -0,0 +1,61 @@ + + + + + diff --git a/src/main/resources/templates/themes/fresh-mint2/home.html b/src/main/resources/templates/themes/fresh-mint2/home.html new file mode 100644 index 0000000..4da07a1 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/fresh-mint2/list.html b/src/main/resources/templates/themes/fresh-mint2/list.html new file mode 100644 index 0000000..2caac35 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/list.html @@ -0,0 +1,58 @@ + + + + +
+ +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/main/resources/templates/themes/fresh-mint2/single.html b/src/main/resources/templates/themes/fresh-mint2/single.html new file mode 100644 index 0000000..da14b58 --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/main/resources/templates/themes/fresh-mint2/theme.json b/src/main/resources/templates/themes/fresh-mint2/theme.json new file mode 100644 index 0000000..3d9a1be --- /dev/null +++ b/src/main/resources/templates/themes/fresh-mint2/theme.json @@ -0,0 +1,21 @@ +{ + "specVersion": 1, + "name": "fresh-mint2", + "title": "清新薄荷·II", + "version": "1.0.0", + "author": "yunhai", + "description": "清新薄荷风(卡片封面版):主色 #17A2A2、浅薄荷底、白卡 12px 圆角;细线吸顶顶栏 + 居中 logo;列表为带封面的卡片网格(一行 3 张);几何圆点装饰与大留白;适合职业院校。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色(薄荷绿)", "type": "color", "default": "#17A2A2" }, + { "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#0F7C7C" }, + { "key": "primarySoft", "cssVar": "--primary-soft", "label": "主色浅调(装饰)", "type": "color", "default": "#E6F6F6" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#F5FBFA" }, + { "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#DCEDEB" }, + { "key": "listLayout", "label": "列表形态", "type": "select", "default": "grid", + "options": [ { "value": "grid", "label": "卡片网格(一行三张)" }, { "value": "list", "label": "简约列表" } ] }, + { "key": "decoration", "label": "背景装饰", "type": "select", "default": "dots", + "options": [ { "value": "dots", "label": "几何圆点" }, { "value": "plain", "label": "纯净无装饰" } ] } + ] +} diff --git a/src/main/resources/templates/themes/mint-breeze/README.md b/src/main/resources/templates/themes/mint-breeze/README.md new file mode 100644 index 0000000..b7a5e6b --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/README.md @@ -0,0 +1,36 @@ +# mint-breeze 皮肤包 + +由 blue-white 派生的清新薄荷风皮肤(docs/10 契约 §5 工作流)。**升级随版本发布,不在站点间复制目录**。 + +> 注:本包与 fresh-mint 为同源并存的两个车道产物(并发会话分道,2026-09-08),互不依赖;站点二选一即可。 + +## 风格定位 + +清爽、亲和,适合职业院校门户:薄荷青品牌色、圆角 12px、卡片纯白+柔和阴影、细线顶栏+居中 logo(桌面吸顶,移动端取消)、列表卡片网格(一行 3 张)、几何圆点装饰与大留白;无纹理、无通栏大图、无复杂动效。 + +## 数据契约(模板可取用的 model 属性全集,契约 §3) + +| 页面 | 属性 | +| --- | --- | +| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/mint-breeze`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) | +| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) | +| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) | +| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` | +| single | `category`(含 `content`)、`currentCategoryId` | + +原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。 + +## 轮播图尺寸约定(bannerSize) + +1920x560(管理端上传端按此引导裁剪,D2 决策点)。 + +## 可调参数 + +见 `theme.json` 的 `params`: + +- **color**(进 CSS 变量):`primary` 主色 / `primaryDark` 主色加深(即强调色,用于品牌文字与悬停加深)/ `primaryLight` 主色提亮 / `bg` 页面底色 / `cardBg` 卡片底色 / `border` 边框色 / `text` 正文文字 / `textLight` 次要文字。 +- **select**:`listLayout` = `grid`(默认,卡片网格一行 3 张)或 `row`(传统行列表)。 + +换色建议:主色系三值同步调整(primary / primaryDark / primaryLight),bg 与 border、primaryLight 保持同族浅色调,避免底色与卡片对比失衡。 + +优先级:`custom_css` > `themeVarsCss` > `main.css`。 diff --git a/src/main/resources/templates/themes/mint-breeze/assets/css/main.css b/src/main/resources/templates/themes/mint-breeze/assets/css/main.css new file mode 100644 index 0000000..33466ef --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/assets/css/main.css @@ -0,0 +1,590 @@ +/* ============================================================ + * mint-breeze 皮肤包样式(blue-white 派生,docs/10 皮肤包契约 §5) + * 清新薄荷风:薄荷青品牌色 + 圆角 12px + 柔和阴影 + 卡片网格列表 + * + 几何圆点装饰与大留白;无纹理、无通栏大图、无复杂动效。 + * 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪)。 + * ============================================================ */ +:root { + --primary: #17A2A2; + --primary-dark: #0F7C7C; + --primary-light: #E6F6F6; + --text: #2F3E3C; + --text-light: #7D8F8C; + --bg: #F5FBFA; + --card-bg: #FFFFFF; + --border: #DDF0EE; + --radius: 12px; + --radius-sm: 8px; + --radius-pill: 999px; + --shadow-soft: 0 6px 18px rgba(15, 124, 124, .08); + --shadow-hover: 0 12px 28px rgba(15, 124, 124, .14); + --max-width: 1200px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); +} +a { color: inherit; text-decoration: none; transition: color .15s ease; } +a:hover { color: var(--primary); } +img { max-width: 100%; } +time, .item-meta { color: var(--text-light); font-size: 13px; } +.empty { color: var(--text-light); text-align: center; padding: 32px 0; } + +/* ===== 头部:细线顶栏 + 居中品牌行 + 白底导航(桌面吸顶) ===== */ +.site-header { + position: sticky; + top: 0; + z-index: 90; + background: var(--card-bg); + box-shadow: var(--shadow-soft); +} +/* 细线顶栏:院系切换在左、站内搜索在右;院系未配置时搜索仍靠右 */ +.top-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + padding: 7px max(16px, calc((100% - var(--max-width)) / 2 + 16px)); + border-bottom: 1px solid var(--border); + font-size: 13px; +} +.top-bar .site-colleges { margin-right: auto; } +/* 居中品牌行:两侧几何圆点装饰(纯 CSS,无纹理) */ +.brand-row { + position: relative; + display: flex; + justify-content: center; + padding: 18px 16px 14px; +} +.brand-row::before, .brand-row::after { + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 46px; + height: 10px; + opacity: .45; + background-image: + radial-gradient(circle, var(--primary) 2.2px, transparent 2.8px), + radial-gradient(circle, var(--primary) 1.6px, transparent 2.2px), + radial-gradient(circle, var(--primary) 1.1px, transparent 1.7px); + background-repeat: no-repeat; + background-position: left center, center center, right center; +} +.brand-row::before { left: 6%; } +.brand-row::after { right: 6%; } +.site-brand { display: flex; align-items: center; gap: 14px; } +.site-logo { height: 52px; } +.site-name { + font-size: 28px; + font-weight: 600; + letter-spacing: .02em; + color: var(--primary-dark); +} +.site-name:hover { color: var(--primary); } +.site-search { display: flex; align-items: center; } +.site-search input { + border: 1px solid var(--border); + border-right: none; + border-radius: var(--radius-pill) 0 0 var(--radius-pill); + padding: 7px 16px; + width: 220px; + outline: none; + background: var(--card-bg); + color: var(--text); + transition: border-color .15s ease, box-shadow .15s ease; +} +.site-search input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} +.site-search button { + border: none; + background: var(--primary); + color: #fff; + padding: 7px 20px; + border-radius: 0 var(--radius-pill) var(--radius-pill) 0; + cursor: pointer; + transition: background .15s ease; +} +.site-search button:hover { background: var(--primary-dark); } + +/* ===== 专业院系下拉(院系子站切换):胶囊开关 + 圆角白卡面板 ===== */ +.site-colleges { position: relative; } +.site-colleges .colleges-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 5px 14px; + cursor: pointer; + color: var(--primary-dark); + font-weight: 600; + white-space: nowrap; + transition: border-color .15s ease, background .15s ease; +} +.site-colleges:hover .colleges-toggle { + border-color: var(--primary); + background: var(--primary-light); +} +.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--primary); } +.site-colleges .colleges-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 200px; + max-height: 420px; + overflow-y: auto; + margin: 0; + padding: 8px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-hover); + z-index: 110; +} +.site-colleges:hover .colleges-menu { display: block; } +.site-colleges .colleges-menu li a { + display: block; + margin: 0 8px; + padding: 9px 12px; + border-radius: var(--radius-sm); + color: var(--text); + white-space: nowrap; +} +.site-colleges .colleges-menu li a:hover { background: var(--primary-light); color: var(--primary-dark); } +.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 主导航:白底 + 柔和文字层级,当前项圆点标记 ===== */ +.site-nav { background: var(--card-bg); border-top: 1px solid var(--border); } +.site-nav ul { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 16px; + list-style: none; + display: flex; + justify-content: center; + flex-wrap: wrap; +} +.site-nav li a { + position: relative; + display: block; + padding: 13px 20px; + color: var(--text); + font-weight: 500; +} +.site-nav li a:hover { color: var(--primary); } +.site-nav li.active a { color: var(--primary); font-weight: 600; } +/* 几何圆点:当前栏目下方的小圆点 */ +.site-nav li.active > a::before { + content: ""; + position: absolute; + left: 50%; + bottom: 5px; + width: 5px; + height: 5px; + transform: translateX(-50%); + border-radius: 50%; + background: var(--primary); +} + +/* ===== 二级下拉导航:圆角白卡面板 ===== */ +.site-nav > ul > li { position: relative; } +.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .7; } +.site-nav ul.sub-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 168px; + max-width: none; + margin: 0; + padding: 8px 0; + list-style: none; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-hover); + z-index: 100; +} +.site-nav li:hover > .sub-nav { display: block; } +.site-nav .sub-nav li a { + margin: 0 8px; + padding: 9px 12px; + border-radius: var(--radius-sm); + color: var(--text); + font-weight: 400; + white-space: nowrap; + background: none; +} +.site-nav .sub-nav li a:hover { background: var(--primary-light); color: var(--primary-dark); } +.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; } + +/* ===== 首页轮播:容器内圆角卡片(非通栏),几何圆点指示器 ===== */ +.banner { + position: relative; + width: min(var(--max-width), calc(100% - 32px)); + margin: 24px auto 0; + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow-soft); +} +.banner-slides { position: relative; height: 400px; } +.banner-slide { display: none; position: absolute; inset: 0; } +.banner-slide.active { display: block; } +.banner-slide img { width: 100%; height: 100%; object-fit: cover; } +.banner-title { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 14px 20px; + color: #fff; + font-weight: 500; + background: linear-gradient(transparent, rgba(20, 63, 61, .62)); +} +.banner-dots { + position: absolute; + bottom: 14px; right: 20px; + display: flex; gap: 8px; +} +.banner-dots button { + width: 9px; height: 9px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, .55); + cursor: pointer; + transition: background .15s ease, transform .15s ease; +} +.banner-dots button.active { background: #fff; transform: scale(1.25); } + +/* ===== 首页区块:卡片网格(头条通栏 + 栏目块一行 3 张) ===== */ +.home-main { + max-width: var(--max-width); + margin: 0 auto; + padding: 28px 16px 56px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} +.top-strip, .category-block { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + padding: 24px; +} +.top-strip { grid-column: 1 / -1; } +/* 栏目标题:几何圆点标记(环形点),标题中等粗细、层级柔和 */ +.section-title, .block-head h2 { + margin: 0 0 14px; + font-size: 19px; + font-weight: 600; + letter-spacing: .02em; + display: flex; + align-items: center; + gap: 10px; +} +.section-title::before, .block-head h2::before { + content: ""; + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 0 3.5px var(--primary-light); +} +.block-head { display: flex; align-items: center; justify-content: space-between; } +.more { + flex: none; + font-size: 13px; + color: var(--text-light); + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + transition: color .15s ease, border-color .15s ease, background .15s ease; +} +.more:hover { + color: var(--primary-dark); + border-color: var(--primary); + background: var(--primary-light); +} +.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; } +.top-strip li, .block-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px dashed var(--border); +} +.top-strip li:last-child, .block-list li:last-child { border-bottom: none; } +.top-strip li a, .block-list li a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.top-badge { + flex: none; + color: var(--primary-dark); + background: var(--primary-light); + font-size: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: var(--radius-pill); +} + +/* ===== 列表页:卡片网格(默认一行 3 张;listLayout=row 切换行列表) ===== */ +.list-main { + max-width: var(--max-width); + margin: 0 auto; + padding: 28px 16px 56px; +} +.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 14px; } +.breadcrumb a { margin-right: 8px; } +.breadcrumb span { margin-left: 8px; } +.page-title { + font-size: 24px; + font-weight: 600; + letter-spacing: .02em; + margin: 0 0 22px; + display: flex; + align-items: center; + gap: 10px; +} +.page-title::before { + content: ""; + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 0 3.5px var(--primary-light); +} +.article-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} +.article-list li { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + padding: 22px 20px; + display: flex; + flex-direction: column; + gap: 10px; + transition: transform .18s ease, box-shadow .18s ease; +} +.article-list li:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); } +.item-title { + font-size: 16px; + font-weight: 600; + line-height: 1.55; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.article-list li:hover .item-title { color: var(--primary); } +.item-summary { + color: var(--text-light); + font-size: 13.5px; + line-height: 1.7; + margin: 0; + flex: 1; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} +.item-meta { + display: flex; + gap: 10px; + align-items: center; + margin-top: auto; + padding-top: 10px; + border-top: 1px dashed var(--border); +} +/* 布局变体:传统行列表(themeParams listLayout=row,见 list.html classappend) */ +.list-rows .article-list { display: block; } +.list-rows .article-list li { display: block; margin-bottom: 16px; } +.list-rows .item-summary { -webkit-line-clamp: 2; flex: none; } +.list-rows .item-meta { border-top: none; padding-top: 0; margin-top: 6px; } + +/* ===== 分页:胶囊页码 ===== */ +.pagination { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; + padding: 28px 0 4px; +} +.pagination a { + min-width: 36px; + text-align: center; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--card-bg); + font-size: 14px; + transition: color .15s ease, border-color .15s ease, background .15s ease; +} +.pagination a:hover { color: var(--primary-dark); border-color: var(--primary); } +.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ===== 详情/单页:整卡承载,正文舒适行距 ===== */ +.detail-main { + max-width: var(--max-width); + margin: 0 auto; + padding: 28px 16px 56px; +} +.detail-main .article { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + padding: 36px 40px; +} +.article-title { + margin: 4px 0 14px; + font-size: 26px; + font-weight: 600; + letter-spacing: .02em; + text-align: center; +} +.article-meta { + display: flex; + gap: 18px; + justify-content: center; + flex-wrap: wrap; + color: var(--text-light); + font-size: 13px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.article-content { line-height: 1.9; font-size: 16px; padding-top: 22px; word-break: break-word; } +.article-content img { height: auto; border-radius: var(--radius-sm); } +.article-content table { border-collapse: collapse; width: 100%; } +.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; } +.article-sibling { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px; font-size: 14px; } +.article-sibling a { + flex: 1 1 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + padding: 14px 18px; + transition: transform .18s ease, box-shadow .18s ease, color .15s ease; +} +.article-sibling a:hover { + color: var(--primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +/* ===== 页脚:浅薄荷底 + 圆点分隔条(三态渲染逻辑不动) ===== */ +.site-footer { + margin-top: 40px; + background: var(--primary-light); + color: var(--text); + border-top: 1px solid var(--border); +} +.site-footer::before { + content: ""; + display: block; + height: 8px; + width: min(var(--max-width), calc(100% - 32px)); + margin: 18px auto 0; + background-image: radial-gradient(circle, var(--primary) 1.6px, transparent 2.2px); + background-size: 16px 8px; + background-repeat: repeat-x; + opacity: .4; +} +.footer-links { + max-width: var(--max-width); + margin: 0 auto; + padding: 18px 16px 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.links-group { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; font-size: 13px; } +.group-name { font-weight: 600; margin-right: 4px; color: var(--primary-dark); } +.links-group a { + padding: 3px 10px; + border-radius: var(--radius-pill); + transition: background .15s ease, color .15s ease; +} +.links-group a:hover { background: rgba(255, 255, 255, .75); color: var(--primary-dark); } +.footer-info { + max-width: var(--max-width); + margin: 0 auto; + padding: 14px 16px 24px; + text-align: center; + font-size: 13px; + color: var(--text-light); +} +.footer-info p { margin: 4px 0; } + +/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */ +.links-group a .link-logo { + height: 20px; + width: auto; + max-width: 64px; + object-fit: contain; + vertical-align: middle; + margin-right: 4px; + border-radius: 4px; +} + +/* ===== 平板(≤992px):网格降为两列,隐藏品牌行圆点装饰 ===== */ +@media (max-width: 992px) { + .home-main { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .article-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .brand-row::before, .brand-row::after { display: none; } +} + +/* ===== 移动端(≤768px):取消吸顶,网格单列,导航横向滑动 ===== */ +@media (max-width: 768px) { + .site-header { position: static; } + .top-bar { padding: 8px 14px; justify-content: flex-start; flex-wrap: wrap; } + .top-bar .site-colleges { margin-right: 0; } + .site-search { flex: 1; } + .site-search input { flex: 1; width: auto; min-width: 0; } + .brand-row { padding: 14px 16px 10px; } + .site-logo { height: 40px; } + .site-name { font-size: 22px; } + .site-nav ul { + justify-content: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .banner-slides { height: 200px; } + .home-main { grid-template-columns: 1fr; padding: 18px 14px 40px; } + .article-list { grid-template-columns: 1fr; } + .detail-main .article { padding: 22px 16px; } + .article-title { font-size: 21px; } + /* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */ + .site-nav ul.sub-nav { + display: block; + position: static; + border: none; + box-shadow: none; + background: transparent; + min-width: 0; + padding: 0 0 6px; + } + .site-nav .sub-nav li a { margin: 0; padding: 8px 16px 8px 34px; border-radius: 0; color: var(--text-light); } + .site-nav .sub-nav li a:hover { background: var(--primary-light); color: var(--primary-dark); } +} diff --git a/src/main/resources/templates/themes/mint-breeze/assets/favicon.png b/src/main/resources/templates/themes/mint-breeze/assets/favicon.png new file mode 100644 index 0000000..497cae1 Binary files /dev/null and b/src/main/resources/templates/themes/mint-breeze/assets/favicon.png differ diff --git a/src/main/resources/templates/themes/mint-breeze/assets/favicon.svg b/src/main/resources/templates/themes/mint-breeze/assets/favicon.svg new file mode 100644 index 0000000..cbfc6c6 --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/themes/mint-breeze/assets/js/main.js b/src/main/resources/templates/themes/mint-breeze/assets/js/main.js new file mode 100644 index 0000000..87fbb17 --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/assets/js/main.js @@ -0,0 +1,40 @@ +/* mint-breeze 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */ +(function () { + var slides = document.querySelectorAll('.banner-slide'); + var dots = document.querySelectorAll('.banner-dots button'); + if (!slides.length) { + return; + } + var current = 0; + var timer = null; + + function show(index) { + slides[current].classList.remove('active'); + if (dots[current]) { + dots[current].classList.remove('active'); + } + current = (index + slides.length) % slides.length; + slides[current].classList.add('active'); + if (dots[current]) { + dots[current].classList.add('active'); + } + } + + function start() { + if (slides.length > 1) { + timer = window.setInterval(function () { + show(current + 1); + }, 5000); + } + } + + dots.forEach(function (dot, index) { + dot.addEventListener('click', function () { + window.clearInterval(timer); + show(index); + start(); + }); + }); + + start(); +})(); diff --git a/src/main/resources/templates/themes/mint-breeze/assets/screenshot.png b/src/main/resources/templates/themes/mint-breeze/assets/screenshot.png new file mode 100644 index 0000000..b6a7d0c Binary files /dev/null and b/src/main/resources/templates/themes/mint-breeze/assets/screenshot.png differ diff --git a/src/main/resources/templates/themes/mint-breeze/detail.html b/src/main/resources/templates/themes/mint-breeze/detail.html new file mode 100644 index 0000000..2e04d6a --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/detail.html @@ -0,0 +1,42 @@ + + + + +
+ +
+ + +
+

文章标题

+ + +
正文
+
+ + +
+ +
+ + diff --git a/src/main/resources/templates/themes/mint-breeze/fragments/footer.html b/src/main/resources/templates/themes/mint-breeze/fragments/footer.html new file mode 100644 index 0000000..1f3f83d --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/fragments/footer.html @@ -0,0 +1,21 @@ + + + +
+ + +
+ diff --git a/src/main/resources/templates/themes/mint-breeze/fragments/head.html b/src/main/resources/templates/themes/mint-breeze/fragments/head.html new file mode 100644 index 0000000..1809482 --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/fragments/head.html @@ -0,0 +1,24 @@ + + + + + + + 页面标题 + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/themes/mint-breeze/fragments/header.html b/src/main/resources/templates/themes/mint-breeze/fragments/header.html new file mode 100644 index 0000000..216bc4b --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/fragments/header.html @@ -0,0 +1,55 @@ + + + + + diff --git a/src/main/resources/templates/themes/mint-breeze/home.html b/src/main/resources/templates/themes/mint-breeze/home.html new file mode 100644 index 0000000..21bfb84 --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/home.html @@ -0,0 +1,57 @@ + + + + +
+ +
+ + + + +
+

头条推荐

+ +
+ + +
+
+

栏目名

+ 更多 > +
+ +

暂无内容

+
+
+ +
+ + + diff --git a/src/main/resources/templates/themes/mint-breeze/list.html b/src/main/resources/templates/themes/mint-breeze/list.html new file mode 100644 index 0000000..882fc1b --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/list.html @@ -0,0 +1,51 @@ + + + + +
+ + +
+ + + +

栏目名

+

搜索词

+ + +

暂无内容

+ + + +
+ +
+ + + diff --git a/src/main/resources/templates/themes/mint-breeze/single.html b/src/main/resources/templates/themes/mint-breeze/single.html new file mode 100644 index 0000000..48e504c --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/single.html @@ -0,0 +1,23 @@ + + + + +
+ +
+ + +
+

栏目名

+ +
单页内容
+

暂无内容

+
+
+ +
+ + diff --git a/src/main/resources/templates/themes/mint-breeze/theme.json b/src/main/resources/templates/themes/mint-breeze/theme.json new file mode 100644 index 0000000..90ad6e5 --- /dev/null +++ b/src/main/resources/templates/themes/mint-breeze/theme.json @@ -0,0 +1,22 @@ +{ + "specVersion": 1, + "name": "mint-breeze", + "title": "薄荷沁风", + "version": "1.0.0", + "author": "yunhai", + "description": "清新薄荷风门户:细线顶栏+居中 logo 吸顶、薄荷青品牌色、圆角 12px 卡片网格列表、几何圆点装饰与大留白,清爽亲和,适合职业院校。", + "screenshot": "assets/screenshot.png", + "bannerSize": "1920x560", + "params": [ + { "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#17A2A2" }, + { "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深(强调色)", "type": "color", "default": "#0F7C7C" }, + { "key": "primaryLight", "cssVar": "--primary-light", "label": "主色提亮", "type": "color", "default": "#E6F6F6" }, + { "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#F5FBFA" }, + { "key": "cardBg", "cssVar": "--card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" }, + { "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#DDF0EE" }, + { "key": "text", "cssVar": "--text", "label": "正文文字色", "type": "color", "default": "#2F3E3C" }, + { "key": "textLight", "cssVar": "--text-light", "label": "次要文字色", "type": "color", "default": "#7D8F8C" }, + { "key": "listLayout", "label": "列表布局", "type": "select", "default": "grid", + "options": [ { "value": "grid", "label": "卡片网格(一行 3 张)" }, { "value": "row", "label": "传统行列表" } ] } + ] +} diff --git a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java index 6de37a9..b357d30 100644 --- a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java +++ b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java @@ -92,7 +92,7 @@ class ThemeRenderIT extends CmsIntegrationTestBase { System.out.println("SOURCE-OF=" + themeRegistry.sourceOf("t-ext-ssr") + " DIR=" + themeRegistry.getThemeDir()); themePackageService.upload(TestUsers.superAdmin(), com.yunhai.cms.support.ThemePackageTestSupport.buildZip( - "t-ext-ssr", "t-ext-ssr", true, true, true, true, true, "EXT-SSR-OK")); + "t-ext-ssr", "t-ext-ssr", true, true, true, true, true, "EXT-SSR-OK"), null, null); persistSiteWithTheme("extportal", "t-ext-ssr", "{}"); MvcResult result = mockMvc.perform(get("/portal/extportal/")).andExpect(status().isOk()).andReturn(); @@ -106,7 +106,7 @@ class ThemeRenderIT extends CmsIntegrationTestBase { void externalPackageAssetsServed() throws Exception { themePackageService.upload(TestUsers.superAdmin(), com.yunhai.cms.support.ThemePackageTestSupport.buildZip( - "t-ext-asset", "t-ext-asset", true, true, true, true, true, null)); + "t-ext-asset", "t-ext-asset", true, true, true, true, true, null), null, null); persistSiteWithTheme("extassets", "t-ext-asset", "{}"); // 门户资产路由(匿名) diff --git a/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java b/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java index af659b3..4ba4c09 100644 --- a/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java +++ b/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java @@ -143,22 +143,22 @@ class ThemeApiIT extends CmsIntegrationTestBase { org.springframework.mock.web.MockMultipartFile zip = com.yunhai.cms.support.ThemePackageTestSupport.buildZip( "t-api-fb", "t-api-fb", true, true, true, true, true, null); - assertThatThrownBy(() -> themePackageService.upload(TestUsers.schoolUser(1), zip)) + assertThatThrownBy(() -> themePackageService.upload(TestUsers.schoolUser(1), zip, null, null)) .isInstanceOf(BizException.class) .hasMessageContaining("仅平台超管"); } @Test - @DisplayName("T-W1-14: list 增补字段 source/inUseCount/editable;内置包 editable=false") + @DisplayName("T-W1-14: list 增补字段 source/inUseCount/editable;任务四起内置包 editable=true(元数据可编辑)") void listCarriesNewFields() throws Exception { themePackageService.upload(TestUsers.superAdmin(), com.yunhai.cms.support.ThemePackageTestSupport.buildZip( - "t-api-ext", "t-api-ext", true, true, true, true, true, null)); + "t-api-ext", "t-api-ext", true, true, true, true, true, null), null, null); List list = themeService.list(); com.yunhai.cms.model.vo.ThemeVo def = list.stream() .filter(v -> "default".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new); assertThat(def.getSource()).isEqualTo("builtin"); - assertThat(def.isEditable()).isFalse(); + assertThat(def.isEditable()).isTrue(); assertThat(def.getInUseCount()).isGreaterThanOrEqualTo(0); com.yunhai.cms.model.vo.ThemeVo ext = list.stream() @@ -173,7 +173,7 @@ class ThemeApiIT extends CmsIntegrationTestBase { void deleteInUsePackageRejected() throws Exception { themePackageService.upload(TestUsers.superAdmin(), com.yunhai.cms.support.ThemePackageTestSupport.buildZip( - "t-api-del", "t-api-del", true, true, true, true, true, null)); + "t-api-del", "t-api-del", true, true, true, true, true, null), null, null); com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest(); req.setCode("delsite"); req.setParentSiteId(0); @@ -194,7 +194,7 @@ class ThemeApiIT extends CmsIntegrationTestBase { void deleteUnusedPackageSucceeds() throws Exception { themePackageService.upload(TestUsers.superAdmin(), com.yunhai.cms.support.ThemePackageTestSupport.buildZip( - "t-api-ext2", "t-api-ext2", true, true, true, true, true, null)); + "t-api-ext2", "t-api-ext2", true, true, true, true, true, null), null, null); com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest(); req.setCode("extsite"); req.setParentSiteId(0); @@ -231,7 +231,7 @@ class ThemeApiIT extends CmsIntegrationTestBase { @Test @DisplayName("T-W1-17: 派生+编辑链路——derive(default→my-skin) → PUT meta → list 与资产反映新值") void deriveAndUpdateMetaChain() throws Exception { - themePackageService.derive(TestUsers.superAdmin(), "default", "my-skin"); + themePackageService.derive(TestUsers.superAdmin(), "default", "my-skin", null, null); assertThat(themeRegistry.contains("my-skin")).isTrue(); assertThat(themeRegistry.sourceOf("my-skin")).isEqualTo("external"); @@ -245,7 +245,7 @@ class ThemeApiIT extends CmsIntegrationTestBase { edited.setType(primary.getType()); edited.setCssVar(primary.getCssVar()); edited.setDefaultValue("#123456"); - themePackageService.updateMeta(TestUsers.superAdmin(), "my-skin", "我的皮肤", + themePackageService.updateMeta(TestUsers.superAdmin(), "my-skin", "我的皮肤", null, java.util.Collections.singletonList(edited)); com.yunhai.cms.model.vo.ThemeVo vo = themeService.list().stream() @@ -269,4 +269,31 @@ class ThemeApiIT extends CmsIntegrationTestBase { .hasMessageContaining("仅平台超管"); themePackageService.reload(TestUsers.superAdmin()); } + + // ===== 任务四(2026-09-08 批次):命名表单 + 内置包改名 ===== + + @Test + @DisplayName("任务四: 内置包元数据编辑放开——.meta 覆盖层生效,list 反映新显示名") + void builtinMetaUpdateAllowed() { + themePackageService.updateMeta(TestUsers.superAdmin(), "default", "默认薄荷", "平台内置改名", null); + assertThat(themeRegistry.get("default").getTitle()).isEqualTo("默认薄荷"); + assertThat(themeService.list().stream() + .filter(v -> "default".equals(v.getName())).findFirst() + .orElseThrow(IllegalStateException::new).getTitle()).isEqualTo("默认薄荷"); + assertThat(java.nio.file.Files.exists(themeDir.resolve(".meta").resolve("default.json"))).isTrue(); + } + + @Test + @DisplayName("任务四: upload 带 title/description 表单字段——落位 theme.json 生效且 list 反映") + void uploadWithNamingFields() throws Exception { + org.springframework.mock.web.MockMultipartFile zip = + com.yunhai.cms.support.ThemePackageTestSupport.buildZip( + "t-api-name", "t-api-name", true, true, true, true, true, null); + themePackageService.upload(TestUsers.superAdmin(), zip, "表单命名", "表单描述"); + com.yunhai.cms.model.vo.ThemeVo vo = themeService.list().stream() + .filter(v -> "t-api-name".equals(v.getName())) + .findFirst().orElseThrow(IllegalStateException::new); + assertThat(vo.getTitle()).isEqualTo("表单命名"); + assertThat(vo.getDescription()).isEqualTo("表单描述"); + } } diff --git a/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java b/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java index e8aa45a..e1a6fe1 100644 --- a/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java +++ b/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java @@ -99,7 +99,7 @@ class ThemePackageServiceTest { @Test @DisplayName("T-W1-05: 合规 zip 上传成功——落盘结构完整、返回 ThemeInfo、registry 含之") void uploadValidZipSucceeds() throws Exception { - ThemeInfo info = service.upload(TestUsers.superAdmin(), zipOf("t-zip-ok", "t-zip-ok", true, true, true, true)); + ThemeInfo info = service.upload(TestUsers.superAdmin(), zipOf("t-zip-ok", "t-zip-ok", true, true, true, true), null, null); assertThat(info.getName()).isEqualTo("t-zip-ok"); assertThat(registry.contains("t-zip-ok")).isTrue(); assertThat(registry.sourceOf("t-zip-ok")).isEqualTo("external"); @@ -120,11 +120,11 @@ class ThemePackageServiceTest { } MockMultipartFile evil = new MockMultipartFile("file", "evil.zip", "application/zip", bos.toByteArray()); - assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), evil)) + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), evil, null, null)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("非法路径"); // 合法包同批校验防误伤 - service.upload(TestUsers.superAdmin(), file); + service.upload(TestUsers.superAdmin(), file, null, null); assertThat(Files.exists(themeDir.resolve("t-slip"))).isTrue(); assertThat(Files.exists(themeDir.getParent().resolve("evil.html"))).isFalse(); } @@ -135,7 +135,7 @@ class ThemePackageServiceTest { byte[] big = new byte[21 * 1024 * 1024]; java.util.Arrays.fill(big, (byte) 'x'); MockMultipartFile file = new MockMultipartFile("file", "big.zip", "application/zip", big); - assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), file)) + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), file, null, null)) .hasMessageContaining("20MB"); } @@ -143,7 +143,7 @@ class ThemePackageServiceTest { @DisplayName("T-W1-08: 缺 theme.json 拒绝(报告含'缺 theme.json')") void missingThemeJsonRejected() throws Exception { assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), - zipOf("t-nojson", "t-nojson", false, true, true, true))) + zipOf("t-nojson", "t-nojson", false, true, true, true), null, null)) .hasMessageContaining("缺 theme.json"); } @@ -152,7 +152,7 @@ class ThemePackageServiceTest { void nameDirMismatchRejected() throws Exception { // zip 根目录 t-other 与 theme.json name=t-nojson 不一致 assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), - zipOf("t-nojson", "t-other", true, true, true, true))) + zipOf("t-nojson", "t-other", true, true, true, true), null, null)) .hasMessageContaining("name 与目录名不一致"); } @@ -160,7 +160,7 @@ class ThemePackageServiceTest { @DisplayName("T-W1-10: 缺必备模板/片段/截图拒绝(报告逐条列出)") void missingTemplatesRejected() throws Exception { assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), - zipOf("t-nolist", "t-nolist", true, false, false, false))) + zipOf("t-nolist", "t-nolist", true, false, false, false), null, null)) .hasMessageContaining("list.html") .hasMessageContaining("fragments/head.html") .hasMessageContaining("screenshot"); @@ -170,7 +170,7 @@ class ThemePackageServiceTest { @DisplayName("T-W1-11: 与内置同名拒绝(name=default),内置包不受影响") void builtinNameRejected() throws Exception { assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), - zipOf("default", "default", true, true, true, true))) + zipOf("default", "default", true, true, true, true), null, null)) .hasMessageContaining("内置"); assertThat(registry.sourceOf("default")).isEqualTo("builtin"); assertThat(registry.get("default").getTitle()).isNotBlank(); @@ -179,22 +179,108 @@ class ThemePackageServiceTest { @Test @DisplayName("T-W1-12: 外部同名=覆盖更新(目录替换、registry schema 更新)") void externalSameNameOverwrites() throws Exception { - service.upload(TestUsers.superAdmin(), zipOf("t-zip-ov", "t-zip-ov", true, true, true, true)); + service.upload(TestUsers.superAdmin(), zipOf("t-zip-ov", "t-zip-ov", true, true, true, true), null, null); ThemeInfo first = registry.get("t-zip-ov"); String oldTitle = first.getTitle(); MockMultipartFile v2 = zipOf("t-zip-ov", "t-zip-ov", true, true, true, true); // 修改 title 便于断言覆盖生效 - service.updateMeta(TestUsers.superAdmin(), "t-zip-ov", oldTitle + "-v2", null); + service.updateMeta(TestUsers.superAdmin(), "t-zip-ov", oldTitle + "-v2", null, null); ThemeInfo updated = registry.get("t-zip-ov"); assertThat(updated.getTitle()).isEqualTo(oldTitle + "-v2"); // 重新上传同包 → 目录被替换、注册仍有效(schema 更新语义) - service.upload(TestUsers.superAdmin(), v2); + service.upload(TestUsers.superAdmin(), v2, null, null); assertThat(registry.contains("t-zip-ov")).isTrue(); assertThat(Files.exists(themeDir.resolve("t-zip-ov").resolve("theme.json"))).isTrue(); } + // ===== C 级修复 + 任务四(2026-09-08 批次)===== + + @Test + @DisplayName("C修复: 兜底 wrapFailure 先记原始堆栈(ERROR+Throwable)再包装,文案保持既有格式") + void wrapFailureLogsOriginalStack() { + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) + org.slf4j.LoggerFactory.getLogger(ThemePackageService.class); + ch.qos.logback.core.read.ListAppender appender = + new ch.qos.logback.core.read.ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + RuntimeException original = new RuntimeException("boom-io"); + com.yunhai.platform.apistd.BizException wrapped = + ThemePackageService.wrapFailure("上传", "皮肤包处理失败:", original); + assertThat(wrapped.getMessage()).isEqualTo("皮肤包处理失败:boom-io"); + assertThat(appender.list).anySatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(ch.qos.logback.classic.Level.ERROR); + assertThat(event.getThrowableProxy().getClassName()) + .isEqualTo(RuntimeException.class.getName()); + assertThat(event.getThrowableProxy().getMessage()).isEqualTo("boom-io"); + }); + } finally { + logger.detachAppender(appender); + } + } + + @Test + @DisplayName("任务四: 上传表单命名——落位前改写 theme.json,title/description 生效") + void uploadWithNaming() throws Exception { + ThemeInfo info = service.upload(TestUsers.superAdmin(), + zipOf("t-zip-name", "t-zip-name", true, true, true, true), "薄荷绿上传", "测试描述"); + assertThat(info.getTitle()).isEqualTo("薄荷绿上传"); + assertThat(info.getDescription()).isEqualTo("测试描述"); + String json = new String(Files.readAllBytes( + themeDir.resolve("t-zip-name").resolve("theme.json")), StandardCharsets.UTF_8); + assertThat(json).contains("薄荷绿上传"); + } + + @Test + @DisplayName("任务四: 添加皮肤包(派生+命名)——name/title/description 一次写入,不再 regex 误替换") + void deriveWithNaming() { + ThemeInfo info = service.derive(TestUsers.superAdmin(), "default", + "t-derive-name", "我的薄荷", "派生描述"); + assertThat(info.getName()).isEqualTo("t-derive-name"); + assertThat(info.getTitle()).isEqualTo("我的薄荷"); + assertThat(info.getDescription()).isEqualTo("派生描述"); + assertThat(registry.sourceOf("t-derive-name")).isEqualTo("external"); + } + + @Test + @DisplayName("任务四: 内置包改显示名——.meta 覆盖层生效、name 不变;带 params 拒绝;空值恢复默认") + void builtinMetaRenameAndReset() throws Exception { + String originalTitle = registry.get("default").getTitle(); + + // 改名:覆盖层落盘、registry 生效、来源仍 builtin + ThemeInfo renamed = service.updateMeta(TestUsers.superAdmin(), "default", "默认皮肤·薄荷", "平台内置", null); + assertThat(renamed.getTitle()).isEqualTo("默认皮肤·薄荷"); + assertThat(renamed.getDescription()).isEqualTo("平台内置"); + assertThat(Files.exists(themeDir.resolve(".meta").resolve("default.json"))).isTrue(); + assertThat(registry.sourceOf("default")).isEqualTo("builtin"); + + // 内置包带 params schema → 明确拒绝 + assertThatThrownBy(() -> service.updateMeta(TestUsers.superAdmin(), "default", null, null, + new ArrayList<>())) + .hasMessageContaining("参数 schema 不可修改"); + + // 恢复默认:title/description 全空 → 覆盖层删除、标题还原 + service.updateMeta(TestUsers.superAdmin(), "default", "", "", null); + assertThat(Files.exists(themeDir.resolve(".meta").resolve("default.json"))).isFalse(); + assertThat(registry.get("default").getTitle()).isEqualTo(originalTitle); + } + + @Test + @DisplayName("任务四: .meta 目录不干扰外部包扫描,reload 后 registry 完整") + void metaDirDoesNotDisturbScan() throws Exception { + int before = registry.list().size(); + Path metaDir = themeDir.resolve(".meta"); + Files.createDirectories(metaDir); + Files.write(metaDir.resolve("default.json"), + "{\"title\":\"覆盖名\"}".getBytes(StandardCharsets.UTF_8)); + registry.reload(); + assertThat(registry.list().size()).isEqualTo(before); + assertThat(registry.get("default").getTitle()).isEqualTo("覆盖名"); + } + /** JDK8 递归删除(测试临时目录专用) */ private static void deleteRecursively(Path path) { if (!Files.exists(path)) {