feat(theme): 皮肤包管理收敛与命名批次-兜底catch经wrapFailure先log.error记原始堆栈再包装(upload/derive/updateMeta三处,extractZip/deleteRecursively静默吞改warn/debug);内置包改显示名走.meta覆盖层(ThemeRegistry加载后覆写title/description,updateMeta放开内置:全空=恢复默认,params拒改);derive/upload支持表单命名(theme.json改写regex换ObjectMapper防误替换,upload落位前改staging失败不落位);resolveTheme回退warn补站点编码;ThemeVo.editable语义改为可编辑元数据;附内置包mint-breeze/fresh-mint2与R1学校主站编码迁移SQL;308/308全绿
build / build-push (push) Successful in 48s

This commit is contained in:
chenxing
2026-09-08 20:25:43 +08:00
parent 12a492fc55
commit a666e13f56
39 changed files with 2343 additions and 72 deletions
@@ -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);
}
@@ -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;
}
@@ -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<ThemeParam> params;
}
@@ -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;
}
@@ -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;
}
@@ -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<String> report) throws IOException {
String declaredName = null;
private String extractZip(MultipartFile file, Path staging, List<String> 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<ThemeParam> params) {
/**
* 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读,
* 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。
*/
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> 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);
}
}
}
@@ -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<Path> scanExternalPackages() {
List<Path> packages = new ArrayList<>();
protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
if (themeDir == null || themeDir.isEmpty()) {
return packages;
}
@@ -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`。
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#5486df"/><stop offset="1" stop-color="#214b97"/>
</linearGradient></defs>
<rect width="64" height="64" fill="url(#bg)"/>
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

@@ -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();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</a>
<span>正文</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${article.title}">文章标题</h1>
<div class="article-meta">
<time th:if="${article.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
<span th:if="${!#strings.isEmpty(article.authorName)}"
th:text="'作者:' + ${article.authorName}">作者</span>
<span th:if="${!#strings.isEmpty(article.source)}"
th:text="'来源:' + ${article.source}">来源</span>
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
</div>
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
其余字段一律 th:text 转义输出 -->
<div class="article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="article-sibling">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}"
th:text="'上一篇:' + ${prevArticle.title}">上一篇</a>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚:浅薄荷底 + 圆点装饰 + 友情链接(分组渲染,方案 §8.1:友链不设独立路由) -->
<footer class="site-footer" th:fragment="footer">
<!-- 纯装饰几何圆点(无数据绑定,aria-hidden) -->
<div class="footer-decor" aria-hidden="true"></div>
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="links-group" th:each="group : ${site.linkGroups}">
<span class="group-name" th:text="${group.groupName}">分组</span>
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</div>
</div>
<div class="footer-info">
<p th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
</footer>
</html>
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) -->
<head th:fragment="head">
<meta name="theme-color" content="#17A2A2">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
</html>
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部:细线顶栏 + 居中品牌行(左:专业院系 / 中:品牌 / 右:站内搜索)+ 栏目导航(《院系子站方案》) -->
<header class="site-header" th:fragment="header">
<div class="header-inner">
<!-- 左:专业院系下拉;顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="header-aside header-aside-left">
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
</div>
<!-- 中:居中品牌(logo + 站名) -->
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<!-- 右:站内搜索 -->
<div class="header-aside header-aside-right">
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul class="top-grid" th:classappend="${themeParams['listLayout'] == 'list'} ? 'is-list'">
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="list-main">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list" th:classappend="${themeParams['listLayout'] == 'list'} ? 'is-list'">
<li th:each="item : ${listData.items}">
<a class="item-card" th:href="${url.article(item.id)}">
<!-- 封面(契约 §3.3 listData.items[].coverImage;上传图走 url.upload) -->
<span class="item-cover" th:if="${item.coverImage != null and !#strings.isEmpty(item.coverImage)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
</span>
<span class="item-body">
<span class="item-title" th:text="${item.title}">文章标题</span>
<span class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</span>
<span class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</span>
</span>
</a>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body th:classappend="${themeParams['decoration'] == 'plain'} ? 'decor-plain'">
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -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": "纯净无装饰" } ] }
]
}
@@ -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`。
@@ -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); }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#5486df"/><stop offset="1" stop-color="#214b97"/>
</linearGradient></defs>
<rect width="64" height="64" fill="url(#bg)"/>
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

@@ -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();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1,42 @@
<!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>
<main class="detail-main">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</a>
<span>正文</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${article.title}">文章标题</h1>
<div class="article-meta">
<time th:if="${article.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
<span th:if="${!#strings.isEmpty(article.authorName)}"
th:text="'作者:' + ${article.authorName}">作者</span>
<span th:if="${!#strings.isEmpty(article.source)}"
th:text="'来源:' + ${article.source}">来源</span>
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
</div>
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
其余字段一律 th:text 转义输出 -->
<div class="article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="article-sibling">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}"
th:text="'上一篇:' + ${prevArticle.title}">上一篇</a>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚:联系信息 + 友情链接(分组渲染,方案 §8.1:友链不设独立路由) -->
<footer class="site-footer" th:fragment="footer">
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="links-group" th:each="group : ${site.linkGroups}">
<span class="group-name" th:text="${group.groupName}">分组</span>
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</div>
</div>
<div class="footer-info">
<p th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
</footer>
</html>
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) -->
<head th:fragment="head">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle}">页面标题</title>
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
th:content="${seoDescription}">
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
<link rel="icon" type="image/png"
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
</head>
</html>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部(mint-breeze):细线顶栏(院系切换+搜索)→ 居中品牌行 → 白底导航;th:* 数据绑定与 blue-white 基准逐字一致 -->
<header class="site-header" th:fragment="header">
<div class="top-bar">
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<span class="colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="colleges-arrow">▾</i>
</span>
<ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li>
</ul>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
<div class="brand-row">
<div class="site-brand">
<img th:if="${site.config['logo'] != null}" class="site-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
th:text="${cat.name}">栏目</a>
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}" th:text="${cat.name}">栏目</a>
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}">
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
th:text="${sub.name}">子栏目</a>
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
</li>
</ul>
</li>
</ul>
</nav>
</header>
</html>
@@ -0,0 +1,57 @@
<!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>
<main class="home-main">
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
<div class="banner-slides">
<a class="banner-slide" th:each="b, stat : ${banners}"
th:classappend="${stat.first} ? 'active'"
th:href="${b.linkUrl}" th:title="${b.title}">
<img th:src="${b.imageUrl}" th:alt="${b.title}">
<span class="banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<div class="banner-dots" th:if="${#lists.size(banners) > 1}">
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
th:aria-label="${'第' + (stat.count) + '张'}"></button>
</div>
</section>
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
<ul>
<li th:each="item : ${topArticles}">
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
</section>
<!-- 一级栏目区块(每栏目 top 8) -->
<section class="category-block" th:each="block : ${categoryBlocks}">
<div class="block-head">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
<li th:each="item : ${block.items}">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,51 @@
<!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>
<!-- listLayout 布局开关(theme.json select 参数):row=传统行列表,默认/未知值=卡片网格 -->
<main class="list-main" th:classappend="${themeParams['listLayout'] != null and themeParams['listLayout'] == 'row'} ? 'list-rows'">
<nav class="breadcrumb" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<nav class="breadcrumb" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span>搜索结果</span>
</nav>
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="article-list">
<li th:each="item : ${listData.items}">
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
<div class="item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
<!-- 分页(列表页/搜索页共用) -->
<nav class="pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}">
<a th:if="${cur > 1}"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}">上一页</a>
<a th:each="p : ${#numbers.sequence(1, total)}"
th:classappend="${p == cur} ? 'active'"
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
th:text="${p}">1</a>
<a th:if="${cur < total}"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
</nav>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<main class="detail-main">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<span th:text="${category.name}">栏目</span>
</nav>
<article class="article">
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="article-content" th:utext="${category.content}">单页内容</div>
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
</article>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
</body>
</html>
@@ -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": "传统行列表" } ] }
]
}
@@ -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", "{}");
// 门户资产路由(匿名)
@@ -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<com.yunhai.cms.model.vo.ThemeVo> 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("表单描述");
}
}
@@ -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<ch.qos.logback.classic.spi.ILoggingEvent> 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)) {