Compare commits

...
Author SHA1 Message Date
chenxing efd3cb30f9 [CMS-VISUAL] D 测试: PortalDComponentsIT 5 用例(骨架渲染/topic/event/空态 R-44/日期排序/形状参数)+ PersonServiceIT 4 + HomeLayout 校验 2 + purgeTables 清 cms_person 2026-09-28 14:04:38 +08:00
chenxing 4cb435b715 [CMS-VISUAL] D1-D10 后端: 新组件数据槽(banner 扩 topic/event 位 5 列 + cms_person 新表+CRUD+防跨站)+ 6 区块类型校验与装配(空数据整块不渲染 R-44)+ 骨架新组件 fragment/内置 22 图标集/共享动效 JS(reveal+count-up R-42)+ searchStyle 搜索浮层(D-10,含坑④条件包裹修复)+ 三保留主题 4 新参数 2026-09-28 14:04:19 +08:00
chenxing 089cb54011 [CMS-VISUAL] F2: 门户布局区块输出稳定 id + data-plb-id 锚点(display:contents 零布局影响)+ postMessage 预览监听脚本(门户侧 50 测试绿) 2026-09-28 12:52:45 +08:00
chenxing c60f9dffb7 [CMS-VISUAL] F3: 首页展示形态唯一入口收敛到布局编辑器——栏目保存缺省 homeStyle 时保留存量值,管理弹窗移除该下拉(19/19 IT 绿) 2026-09-28 12:32:34 +08:00
chenxing 19e9c18c96 feat(theme): S7 炫彩校园主题补齐——3 形态自研+2 新区块+svc/guide 页族,A 档三包整项自审收口
build / build-push (push) Successful in 49s
- fragments/home-blocks.html 150→280(纯追加 130 行):news_grid/media_focus/image_wall 三形态
  由委派 common::plb* 改为本包自持,并补 quick_links/notice_bar;空态用内联 div.cg-empty
  (与 scholar-blue/mint-breeze 的 ::empty 子片段策略分裂,记 R-37 未统一)
- home.html 分派改双层 th:switch(case 父 / replace 子,坑④)并引入 common :: style(R-32 本包实例关闭)
- assets/css/main.css 402→656(纯追加 254 行,0 删):新节全部 .cg-svc-*/.cg-chip/.cg-tag/.cg-sec-card
  等全新类名,未重写任何既有选择器;坑③ 在本包论证不适用(.cg-home 非网格,全幅 section 天然占满)
- 新建 service.html(135)/guide.html(134):poly-blue 页族换皮,绑定面 diff 0 差异
- theme.json 零改动(P-A3 步骤 6「不动参数」,实测 9 参保持)
- 后端全量 412/412 + 前端 160/160 绿;本次 S10 复跑后端 412、前端 166 绿
2026-09-22 21:54:35 +08:00
chenxing 854204d1c0 feat(theme): S6 薄荷沁风主题改造——自研 home-blocks 10 形态+2 新区块、svc/guide 页、三列网格区块显式跨行
build / build-push (push) Successful in 55s
- fragments/home-blocks.html 新建 238 行 11 fragment(8 形态 + quick_links/notice_bar + empty),
  坑①空态守卫写在外层 th:block、坑②新增可选键一律 block['key'] 括号访问
- home.html 双层 th:switch(case 在父、replace 在子,坑④),未知类型仍回落 common :: render
- assets/css/main.css 617→1123 行:.home-main 三列网格下每个区块根类显式 grid-column:1/-1(坑③最高风险点)
- service.html/guide.html 新建(poly-blue 平移换皮,绑定面零差异);theme.json params 9→10 新增 headerStyle
- fragments/header.html 1 行接 themeParams['headerStyle']
- 其余 10 套包零改动;后端全量 412/412 绿(skip.tests=false,3307 侧车库)
2026-09-22 21:53:43 +08:00
chenxing bc794ccce3 feat(theme): S5 学院蓝主题改造——home-blocks/service/guide页+区块CSS+ThemeRegistry增强
build / build-push (push) Successful in 53s
2026-09-22 17:35:38 +08:00
chenxing 82d6ff4602 feat(theme): 新增三套高校官网风皮肤 poly-blue/agri-forest/jade-normal
build / build-push (push) Successful in 51s
以 default 为基座(文件最全:含事项库 service/guide 两模板),设计语言与配色取自
真实高校官网 CSS(浙理工 #0558A5+橙、信阳农林 #1C6E19+麦穗金、信阳师大 #008D3F+暖橙)。
三包均自带九段 home-blocks、:root 契约变量别名、中文防塌列三件套、真实实拍截图
(1920 无头截图缩样)。

- UniversitySkinContractTest(7 用例):九段 fragment/homeStyle 分发/防塌列/变量别名/
  层序/自有截图/主色色域(蓝或绿通道主导,粉紫禁入)
- ThemeRegistryTest +哨兵:三包注册成功且主色与 default/scholar-blue 及彼此互异
- jade-normal 修复:news_grid 全宽封面变体把正文列压成 0 宽致 390px 溢出 47px,
  增 .jn-feat-a--stack 纵向变体,复测 overflowX=0
- 门禁:ThemeContractIT 14 主题×7 路由全绿;全量 mvn test 393/393 绿
2026-09-20 02:31:53 +08:00
chenxing d2df12b91e feat(theme)!: 下线粉/紫两包,改以学院蓝为基座交付 academic-navy/campus-azure 两套高校官网风皮肤
build / build-push (push) Successful in 51s
线上 10027 首页配了 home_layout 后走 themes/common 通用骨架,派生皮肤只换了页头页脚,
正文仍是骨架内置蓝灰卡(外加 cherry-bloom 的粉紫配色),用户判定"完全不能用"。

- 新增 academic-navy(学府藏蓝,an-)/campus-azure(青蓝校园,cz-):以 scholar-blue 为基座,
  各自自带 fragments/home-blocks.html 与 home.html 分发,news_grid/media_focus/compact/
  image_wall/card/ticker/gallery/links 八种区块形态全部由皮肤渲染,未知类型才回落通用骨架
- 两套皮肤 :root 把私有变量别名到 --primary/--text/--card-bg/--border/--radius 契约变量;
  中文行锁 nowrap+min-width:0+ellipsis,grid 轨道用 minmax(0,1fr) 防窄视口压塌
- 新闻背景带 .an-sec-bg/.cz-sec-bg 压 z-index:0、卡片抬到 1:绝对定位层默认画在普通流之上,
  不抬起会整张卡片泛成底图色(无头截图实测)
- common 骨架空态守卫改包 th:block(th:replace 优先级 100 先于 th:if 300)
- 删除 cherry-bloom/violet-iris;sql/upgrade_theme_academic_skins.sql 幂等改指存量站点
  (站点缓存 CacheNames.SITE TTL 30 分钟,改库后需重启或在管理端重存站点)
- 测试:AcademicSkinContractTest(区块自带/分发/CJK 行/变量别名/主色必须学术蓝/背景带层级/
  预览图不得沿用基座)、HomeBlocksCssContractTest、PortalHomeLayoutIT 端到端断言零 plb- 输出;
  全量 383 项通过
2026-09-19 20:21:30 +08:00
chenxing 52433f06a2 fix(theme): 首页/列表移动端 1fr 网格改 minmax(0,1fr) 消除窄视口横向溢出;新包落位真实截图与 favicon
build / build-push (push) Successful in 50s
2026-09-19 17:47:41 +08:00
chenxing 085c284829 feat(theme): 新增 cherry-bloom/violet-iris 两套门户皮肤并应用于视觉种子院系子站 2026-09-19 17:18:19 +08:00
chenxing c44c35bf2a fix(portal): 错误页返回首页链接改 th:href=@{/portal/} 感知 context-path(R-04) 2026-09-19 17:18:10 +08:00
chenxing 8839ef8a5f feat(site): /oapi/site/list 按校查询只下发主站——院系子站不接入办事大厅
build / build-push (push) Successful in 50s
- 带 schId 过滤 parentSiteId=0(办事大厅按校接入,一校一主站,SiteOapiIT 冻结契约)
- 不传 schId 全量仍含子站:svc-hall resolveSite 反解 siteCode→schId 依赖,历史办件可继续定位
2026-09-17 14:51:07 +08:00
chenxing 89a5a346d3 fix(portal): 服务中心/指南页顶导航高亮——外链栏目匹配为当前栏目,首页不再误高亮(8 主题兜底 navServiceActive)
build / build-push (push) Successful in 1m43s
2026-09-17 01:10:14 +08:00
chenxing 649ad66264 refactor(sdk)!: CMS→svc-hall 出站改走 YschOpenCall 命名目标 + 适配 SDK 领域分包
build / build-push (push) Successful in 1m36s
- SvcHallClient:由手搓 IDHttp + yh.svchall.base-url 拼 URL 改为 YschOpenCall 命名目标
  svchall(服务目录 yh.open.services.svchall)。原形态对带 context-path 的 svc-hall
  必 401(共享拦截器只剥 yh.open.base-path=/userbase);per-target base-path 与提供方
  ctx 对齐后签名天然一致。目录未配时回落 legacy 键;两者皆缺则构造期 fail-fast。
- SvcItemRemoteQuery:新增 businessAbsent 判定,统一两条出站路径的「业务 code=2 →
  404 哨兵/空集,其余上抛」语义(目录路径抛 DException,legacy 抛 DHttpException)。
- import 迁移:YschBaseOpenApi → openapi.base.*(SDK 领域分包)。
- application.yml:新增 yh.open.services.svchall,删 yh.svchall.base-url。
- 测试:新增 SvcHallClientTest(8 例:目录目标/参数映射/空参不下发/fire-and-forget/
  回落/fail-fast);SvcItemRemoteQueryTest 补目录路径错误语义 4 例。104 测试全绿。
2026-09-16 21:05:55 +08:00
chenxing 1cb95225a6 周末修订
build / build-push (push) Successful in 3m59s
2026-09-15 23:41:34 +08:00
chenxing e4b843cf00 refactor(base): GetUserMod 改为消费用户中心下发的模块树;默认关闭测试执行
build / build-push (push) Successful in 1m9s
两处独立改动:

1) CmsUserCenterService:用户中心已按平台契约 BoaModule.children 下发模块树,本仓原先
   按 parentId 重建父子关系的 buildModuleTree 成为空转(与 Notice/svc-hall 同构的重复实现)。
   现收敛为 toFrontendTree,只做前端 layout 需要的字段改名(path→page、id→modId、补
   usable/orderNumber)并递归映射 children;层级与兄弟顺序以上游 customId 序为准,本侧不
   重排。前端契约不变(children 恒为数组);拿到旧版用户中心的扁平结果(children=null)时
   退化为与今日一致的扁平菜单,不抛 NPE。保留原有平台不可达时的空菜单降级。

2) pom:新增属性 skip.tests(默认 true),surefire 以 <skipTests>${skip.tests}</skipTests>
   消费——测试执行默认关闭。原因:本工程 *IT 集成用例连本地 MySQL/Redis,缺中间件时成片报
   Communications link failure / 上下文加载失败(353 例中约 262 例),噪音掩盖真信号,且
   CI 构建本就是 mvn -DskipTests package,本地空跑约 20 分钟。只跳「执行」,测试源码仍编译,
   接口漂移仍可在编译期发现;需要跑时用 mvn test -Dskip.tests=false 临时打开。
2026-09-15 18:43:24 +08:00
chenxing 9e28092a8b feat(svcitem): 事项库+办事大厅 R2 后端落地,并完成交付后独立审计修复
build / build-push (push) Successful in 2m26s
落地(依据《事项库与办事大厅详细设计 v1》)
- 事项域垂直包 com.yunhai.cms.svcitem.*(entity/enums/repository/service+impl/event/config/model dto+query+vo)
  与 controller/{api,oapi}/svcitem;两表 DDL 进 cms_init.sql(表 7/8)+ upgrade_svcitem_r2.sql
- SvcItemQuery 接缝:门户 8 方法 + 管理端 11 方法统一收口,控制器不再注入实现类
- 门户公开层:doServiceList/doGuide 双路由变体 + M0 可选模板整页回退
  (视图名/themeBase/buildUrl/资产主题段四处同源 effectiveTheme,缺模板整包退 default)
- oapi 跨服务快照:与平台 AppAuthFilter 同口径签名(canonical=method\npath?sortedQuery\ntimestamp,
  HmacSHA256/Base64, 5min 窗口)+ /oapi/* 强制验签过滤器
- default 皮肤 service.html/guide.html + main.css 服务中心/指南页样式段

审计修复(发现与处置全表见 docs/18-事项库审计与修复记录.md)
- D1 附件引用链:内容资产(封面+正文内嵌图)独立场景 svc_item_asset,保存时按内容派生全量同步归属
  (AttachmentService.localAssetUrls/localWebUrl/syncAssets:集合内标记、集合外解除)。
  修复根因:upload 带 scene 时只写 ref_type 不写 ref_id,而保存仅按前端提交 id 重绑,
  正文图与封面停在半绑定态 → 既不在孤儿口径内也不受 release 管辖,成永久无主行
- D5 归属校验:绑定前做可认领判定(无引用/半绑定/已属本事项方可绑),他事项引用与跨站附件跳过并告警
- D2 部门:组类型改配置 cms.svc.item.dept-group-type(默认 2=学校组,校内科室改 3);
  deptTree 返回嵌套组树;新增 SvcDeptSource 接缝使静态 getGroupTree 可替身(此前部门链零覆盖)
- D4 缓存:事件 scope 语义化,dict/dept 变更整体失效 cms:svcItem(展示名快照不再滞后 5min)
- 站点解析:findByCode 在 uk(parent_site_id,code) 下命中多条会抛异常变 500,
  4 处生产调用点改 findFirstByCodeOrderByIdAsc(确定性取最早站点)
- 加固:新增 idx_svc_item_portal(含 DESC 列)对症门户排序 filesort;升级脚本改 information_schema
  +PREPARE 幂等索引守卫块(表在缺索引可重跑修复);clear_cms_data.sql 补事项两表;
  view_count 改有界队列+拒绝丢弃告警;SEL_LIST_MAX 截断告警

测试
- 新增 SvcDeptChainIT 7、SvcItemServiceIT 16(含 D1/D5 回归锚点与全方法权限)、
  SvcItemOapiIT 9(时间戳超窗/未知 appKey/畸形头/siteCode 歧义)、SvcItemPortalIT 11
  (嵌套指南路由/关键词/canonical/浏览量异步)、SvcDictServiceIT 8(改名后缓存即时刷新)
- ThemeContractIT 补"缺可选模板主题资产段必退 default"断言并订正七路由表述
- 全量 mvn -o test:388 测试 0 失败;升级脚本 bsch_cms_test 两跑幂等(含缺索引自动补建)
2026-09-11 14:12:04 +08:00
chenxing 39454e4efb feat(portal): docs/16 首页布局后端- home_layout零DDL消费与主题fragment化-PortalSiteSnapshot.homeLayout+normalizeHomeLayout脏数据容错;PortalPageRenderer.buildLayoutBlocks按区块序列渲染(article_list多栏目聚合+homeStyle别名+category合成+background回落news_bg_image/ticker top|category双源/gallery标题副题缺省旧键/links分组过滤/visible跳过/未知type跳过/size 1~20夹取);布局模式清空topArticles/focusArticles/categoryBlocks/galleryBanners回落段;SiteService.validateConfigJson带siteId收紧home_layout(blocks<=30/type白名单/size 1~20/文本<=100/categoryIds归属校验:新建拒跨站拒不存在拒);themes/common/fragments/home-blocks.html plb-*兜底骨架(不注册皮肤包,Thymeleaf classpath可解析);campus-glow自有fragment(news_grid/media_focus/card/ticker/gallery)原home.html抽出;default+blue-white+classic-red+fresh-mint+mint-breeze+warm-kinder+scholar-blue+campus-glow八套home.html接入布局分支(th:case父元素+th:replace子元素嵌套,replace优先级100先于case执行);新增PortalHomeLayoutIT 5+SiteServiceHomeLayoutIT 3,全量330绿
build / build-push (push) Successful in 52s
2026-09-10 15:08:40 +08:00
chenxing 98d640faba feat(portal): 门户左侧栏目导航+首页视觉升级+主题正名批次-PortalSideNav模型+attachCategoryNavigation注入doCategory/doArticle三件套categoryPath/navTopId/sideNav(站点快照树推导零新增查询,home/search不注入);7主题侧栏两栏markup+窄屏横滚+顶导航祖先高亮+二级下拉当前项高亮+面包屑全路径;cms_category.home_style全链路(news_grid 16条8x2日期前缀/media_focus形态)+PortalListData.Item补source来源徽标四处装配;Banner机制gallery展示位→galleryBanners四列大图轮播(自由字符串零DDL);页脚config 12新键(footer_addresses多校区/qr_*/social_*含URL校验,footerAddressLines由PortalSiteSnapshot.of拆行派生);新增展示型皮肤包campus-glow;fresh-mint2正名fresh-mint(theme.json/头注释同步);AttachmentService scene白名单加category(单页富文本贴图免清孤儿);新增PortalSideNavIT 8+PortalHomeBlocksIT 3,CategoryServiceIT/SiteServiceIT/PortalSsrIT/ThemeContractIT等单类独跑全绿;附sql/clear_cms_data.sql全量清库脚本(清cms_*六表留平台账号/主题包/上传文件,测试数据重走用)
build / build-push (push) Successful in 1m47s
2026-09-10 11:10:48 +08:00
chenxing f4b1749377 refactor: 删除 PropertyEnvConfigSource,env-config 数据源由平台默认挂载文件源兜底(yh-env-config starter B2 下沉)
build / build-push (push) Successful in 1m52s
2026-09-09 11:27:39 +08:00
chenxing a666e13f56 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
2026-09-08 20:25:43 +08:00
chenxing 12a492fc55 feat(env-config): 管理端运行时配置接入-PropertyEnvConfigSource数据源(yh.env-config.source.<target>,target=admin),免登录GET /api/env-config由聚合starter自动装配(旁路CAS),EnvConfigApiIT随机端口匿名200/未配置{}契约(兼平台starter回归哨兵),全量301/301
build / build-push (push) Successful in 1m17s
2026-09-08 17:18:17 +08:00
261 changed files with 29823 additions and 367 deletions
+11
View File
@@ -15,6 +15,9 @@
<description>yh-platform 高校门户 CMS(管理端 API + Thymeleaf 门户 SSR)</description>
<properties>
<java.version>1.8</java.version>
<!-- 测试执行开关:默认关闭(*IT 依赖本地 MySQL/Redis,详见 build 内 surefire 注释);
需要跑测试时用 -Dskip.tests=false 临时打开 -->
<skip.tests>true</skip.tests>
</properties>
<dependencyManagement>
@@ -120,6 +123,14 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!--
测试执行默认关闭(2026-09-15):*IT 依赖本地 MySQL/Redis,缺中间件时成片报
Communications link failure / 上下文加载失败(353 例里约 262 例),噪音掩盖真信号;
CI 构建本就是 mvn -DskipTests package,关掉后本地 mvn test/package 不再空跑约 20 分钟。
只跳过「执行」,测试源码仍会编译,接口漂移仍能在编译期暴露。
需要跑时临时覆盖:mvn test -Dskip.tests=false
-->
<skipTests>${skip.tests}</skipTests>
<includes>
<include>**/*Test.java</include>
<include>**/*IT.java</include>
+45
View File
@@ -0,0 +1,45 @@
-- ============================================================
-- bsch_cms 全量清库脚本(清测试数据,全流程重走)
-- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root)
-- 范围:cms_* 八表全空(站点/栏目/文章/轮播/友链/附件/事项/事项字典),AUTO_INCREMENT 归位
-- 不动的东西:
-- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库)
-- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录)
-- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明)
-- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。
-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"各表为空",本脚本正好满足)。
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE cms_attachment;
TRUNCATE TABLE cms_banner;
TRUNCATE TABLE cms_link;
TRUNCATE TABLE cms_article;
TRUNCATE TABLE cms_category;
TRUNCATE TABLE cms_service_item;
TRUNCATE TABLE cms_service_dict;
TRUNCATE TABLE cms_site;
SET FOREIGN_KEY_CHECKS = 1;
-- 验证:八表应全为 0
SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site
UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category
UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article
UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner
UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link
UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment
UNION ALL SELECT 'cms_service_item', COUNT(*) FROM cms_service_item
UNION ALL SELECT 'cms_service_dict', COUNT(*) FROM cms_service_dict;
-- ------------------------------------------------------------
-- 可选(不在 SQL 内):清掉测试期上传的物理文件,只留种子静态资产。
-- 保留:D:/bsch/cms/upload/seed/ (cms_seed_local.sql 的配图依赖它)
-- 删除:seed/ 之外的目录与散文件(测试期真实上传的图片/PDF 均为孤儿)
-- Git Bash 下参考:
-- find /d/bsch/cms/upload -mindepth 1 -maxdepth 1 ! -name seed -exec rm -rf {} +
-- 主题包如需一并重置(连派生皮肤一起清掉,回到内置包):
-- rm -rf <服务运行目录>/data/themes (重启服务后仅剩 classpath 内置包)
-- ------------------------------------------------------------
+3 -2
View File
@@ -21,9 +21,10 @@ WHERE code = 'demo';
UPDATE cms_banner SET status = 0 WHERE id = 5;
-- 2) 院系子站(demo 的子站:header 出"专业院系"下拉;不配 public_base_url=按请求派生)
-- 主题应用:jdx→academic-navy(学府藏蓝)、xxgc→campus-azure(青蓝校园)——两套高校官网风皮肤随种子落地可视验收
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'default', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'default', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'academic-navy', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'campus-azure', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES
(61, 6, 0, '学院概况', 2, NULL, '<p>机电学院(视觉精修种子)。</p>', 1, 1, 'cmssuper', NOW()),
(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
+35
View File
@@ -0,0 +1,35 @@
-- ============================================================
-- R1 迁移脚本:学校主站编码改 5 位数字码(1 + 学校ID左补零至4位)
-- 例:sch_id=13 → code/path = 10013;幂等(只处理编码≠目标值的行)
-- 前置:W4 嵌套 path 机制已落地(cms_site.path 列已回填,uk_site_path 已建)
-- 旧链接提示:执行后 /portal/sch-13/ 不再解析(301 兼容仅覆盖子站按 code
-- 回查,主站不在其列);已对外发链需在 Caddy 层加旧前缀 301 或接受断链。
-- ============================================================
-- 0) 前置校验:先人工看一眼存量主站
-- ① 同一 sch_id 挂多个顶层站 → 改码后触发 uk_site_path 冲突,需先合并/停用;
-- ② sch_id 为空的顶层站(如演示站)会被本脚本跳过、保持原编码。
SELECT id, code, sch_id, path, status FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL;
-- 1) 顶层主站:code 与 path 一起改为目标 5 位码(sch_id 全局唯一 ⇒ 天然满足
-- uk_site_path / uk_site_parent_code;sch_id 为空的顶层站跳过)
UPDATE cms_site
SET code = CONCAT('1', LPAD(sch_id, 4, '0')),
path = CONCAT('1', LPAD(sch_id, 4, '0'))
WHERE (parent_site_id = 0 OR parent_site_id IS NULL)
AND sch_id IS NOT NULL
AND code <> CONCAT('1', LPAD(sch_id, 4, '0'));
-- 2) 子站 path 级联重算(code 不动,仅换父前缀;幂等)
UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
SET s.path = CONCAT(p.path, '/', s.code)
WHERE s.parent_site_id > 0
AND s.path <> CONCAT(p.path, '/', s.code);
-- 3) 校验(两条都应返回 0 行)
SELECT id, code, path FROM cms_site WHERE path = '' OR path IS NULL;
SELECT id, code, path FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL;
-- 4) 收尾:缓存为进程内 Caffeine(站点快照 TTL 30min、旧 key 自然过期);
-- 想立即生效可在站点管理保存一次任一站点(触发 SiteContentChangedEvent 清缓存),
-- 或重启 cms 容器。
+138
View File
@@ -0,0 +1,138 @@
-- ============================================================
-- R2 升级脚本:事项库两表(事项库与办事大厅详细设计 §2.5,幂等)
-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本
-- 顺序:加表(CREATE IF NOT EXISTS 幂等)→ 索引守卫补建(幂等)→ 校验
-- 说明:MySQL 无 ADD INDEX IF NOT EXISTS,建表内联索引;存量库若只建了表缺索引
-- (含 r2 首版之后新增的 idx_svc_item_portal),由第 3 段守卫块自动补建,可反复重跑。
-- ============================================================
-- 1) 事项主表
CREATE TABLE IF NOT EXISTS `cms_service_item` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`site_id` INT NOT NULL COMMENT '所属站点',
`item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)',
`title` VARCHAR(200) NOT NULL COMMENT '事项名称',
`summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)',
`object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)',
`category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)',
`dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)',
`dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)',
`handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下',
`online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)',
`handle_place` VARCHAR(200) NULL COMMENT '办理地点',
`handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")',
`promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)',
`contact_phone` VARCHAR(64) NULL COMMENT '咨询电话',
`supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话',
`process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)',
`material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)',
`notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)',
`faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)',
`notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)',
`rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)',
`cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架',
`publish_time` DATETIME NULL COMMENT '发布时间',
`offline_time` DATETIME NULL COMMENT '下架时间',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)',
`ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)',
`create_by` VARCHAR(64) NULL,
`create_time` DATETIME NULL,
`update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`),
KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`),
KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`),
KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`),
KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`),
KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)';
-- 2) 事项字典表
CREATE TABLE IF NOT EXISTS `cms_service_dict` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`site_id` INT NOT NULL COMMENT '所属站点',
`dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别',
`dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)',
`dict_name` VARCHAR(128) NOT NULL COMMENT '展示名',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)',
`ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)',
`create_by` VARCHAR(64) NULL,
`create_time` DATETIME NULL,
`update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`),
KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)';
-- 3) 索引守卫补建(仅当表已存在但缺索引时生效;幂等,可反复重跑)
-- idx_svc_item_portal:门户服务中心列表 ORDER BY sort_no ASC, publish_time DESC, id DESC 的对症索引
-- (原 idx_svc_item_list 前导列含门户未过滤的 online_flag,无法用于该排序 → 必然 filesort)
SET @db := DATABASE();
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'uk_svc_item_code') = 0,
'ALTER TABLE cms_service_item ADD UNIQUE KEY uk_svc_item_code (site_id, item_code)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_list') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_list (site_id, status, online_flag, sort_no)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_obj') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_obj (site_id, status, object_key)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_cat') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_cat (site_id, status, category_key)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_dept') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_dept (site_id, status, dept_group_id)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_portal') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_portal (site_id, status, sort_no ASC, publish_time DESC, id DESC)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_dict'
AND index_name = 'uk_svc_dict') = 0,
'ALTER TABLE cms_service_dict ADD UNIQUE KEY uk_svc_dict (site_id, dict_type, dict_key)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_dict'
AND index_name = 'idx_svc_dict_list') = 0,
'ALTER TABLE cms_service_dict ADD KEY idx_svc_dict_list (site_id, dict_type, status, sort_no)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 4) 校验
-- 4.1) 应返回 0 行(两表存在且注释非空)
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('cms_service_item', 'cms_service_dict')
AND TABLE_COMMENT = '';
-- 4.2) 应返回 10 行(两表全部索引就位:主键 2 + 唯一键 2 + 普通索引 6)
SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name IN ('cms_service_item', 'cms_service_dict')
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
+21
View File
@@ -0,0 +1,21 @@
-- ============================================================
-- 皮肤升级脚本:粉/紫两包下线 → 高校官网风两包接管(幂等,存量库执行一次即可)
-- 背景:cherry-bloom(樱语粉韵)/ violet-iris(鸢尾紫韵)不符合高校门户视觉,已从模板库删除;
-- 存量站点若仍指向这两个主题,ThemeRegistry 匹配不到会静默回落 default(皮肤全丢),
-- 故必须把 theme 列改到新包:academic-navy(学府藏蓝)/ campus-azure(青蓝校园)。
-- 目标库:存量 bsch_cms(生产/预发);干净环境无需本脚本
-- 顺序:备份查询 → 回填 → 校验
-- ============================================================
-- 1) 先看受影响站点(人工确认后再执行第 2 步)
SELECT id, path, name, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
-- 2) 回填:粉韵→学府藏蓝,紫韵→青蓝校园(仅命中旧值,重复执行无副作用)
UPDATE cms_site SET theme = 'academic-navy' WHERE theme = 'cherry-bloom';
UPDATE cms_site SET theme = 'campus-azure' WHERE theme = 'violet-iris';
-- 3) 校验(应返回 0 行)
SELECT id, path, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
-- 备注:站点快照走 Caffeine CacheNames.SITE,TTL 30 分钟(±60s 抖动,见 CacheConfig),
-- 实测改库后 400s 门户仍是旧皮肤——改库后请重启服务,或在管理端重新保存一次站点(换肤会主动清缓存)。
@@ -51,7 +51,8 @@ public class CacheConfig implements CachingConfigurer {
manager.setCaches(Arrays.asList(
new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()),
new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).build()),
new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build())));
new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build()),
new CaffeineCache(CacheNames.SERVICE_ITEM, caffeine(Duration.ofMinutes(5)).build())));
return manager;
}
@@ -10,6 +10,10 @@ package com.yunhai.cms.config;
* 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)</li>
* <li>{@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上);
* 失效事件 = 该站点文章发布/下架/修改</li>
* <li>{@link #SERVICE_ITEM}:itemId → 事项指南页模型;filters:{siteId} → 三组筛选项+计数,
* TTL 5min±60s(itemId 不存在时 null 哨兵 30s 防穿透);
* 失效事件 = 事项发布/修改/下架/删除、字典变更(ServiceItemChangedEvent,仅事项域,
* 不触碰 cms:article/cms:list/cms:site)</li>
* </ul>
*/
public final class CacheNames {
@@ -20,6 +24,8 @@ public final class CacheNames {
public static final String ARTICLE = "cms:article";
/** 门户栏目列表页 */
public static final String LIST = "cms:list";
/** 事项指南页模型 + 筛选项(key=itemId / filters:{siteId},事项库 §3.6) */
public static final String SERVICE_ITEM = "cms:svcItem";
private CacheNames() {
}
@@ -26,7 +26,7 @@ public class AttachmentController {
private final AttachmentService attachmentService;
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site/category(category=单页栏目贴图),B2 初步标记) */
@PostMapping("/upload")
public AttachmentVo upload(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@@ -0,0 +1,43 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.PersonSaveRequest;
import com.yunhai.cms.model.vo.PersonVo;
import com.yunhai.cms.service.impl.PersonService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/**
* 名师风采人物管理(docs/20 D-7,people 区块数据源)。
*/
@RestController
@RequestMapping("/api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping("/list")
public List<PersonVo> list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) {
return personService.list(authUserWithDept, siteId);
}
@PostMapping("/save")
public PersonVo save(BoaUser authUserWithDept, @Valid @RequestBody PersonSaveRequest request) {
return personService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
personService.delete(authUserWithDept, request);
}
}
@@ -48,27 +48,35 @@ 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 拒改),保存后引用站点立即生效。
* 工作项 T:paramValues(包级参数值)两种来源统一写 .meta 覆盖层,null=不改 */
@PutMapping("/{name}/meta")
public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept,
@PathVariable("name") String name,
@RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) {
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name, req.getTitle(), req.getParams());
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
req.getTitle(), req.getDescription(), req.getParams(), req.getParamValues());
return themeService.voOf(info);
}
@@ -0,0 +1,64 @@
package com.yunhai.cms.controller.oapi.site;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 站点目录开放接口(/oapi/site/list,appKey 签名访问;V2.0 Part C.3)。
* 消费方=办事大厅 svc-hall(CmsSiteDirectoryImpl:resolveSite 反解 siteCode→schId、
* 站点选择器按校拉取;本地缓存 5min)。schId 可选:
* 不传=全部启用站点(跨校全量,供 siteCode 反解 siteCode→schId,旧办件仍可定位);
* 传了=按校过滤且<b>仅主站</b>(parentSiteId=0):院系子站不接入办事大厅(2026-09-17 口径收窄,
* 办事大厅按校接入一间学校只对应主站一个站点),SiteOapiIT 冻结该契约。
*/
@RestController
@RequestMapping("/oapi/site")
@RequiredArgsConstructor
public class SiteOapiController {
private final CmsSiteRepository siteRepository;
@GetMapping("/list")
public List<SiteOptionVo> list(@RequestParam(value = "schId", required = false) Integer schId) {
if (schId == null) {
return siteRepository.findByStatusOrderByIdAsc(1).stream()
.map(SiteOapiController::toVo)
.collect(java.util.stream.Collectors.toList());
}
if (schId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId 非法");
}
return siteRepository.findBySchId(schId).stream()
.filter(site -> site.getStatus() != null && site.getStatus() == 1)
// 仅主站:院系子站不接入办事大厅(反解全量不受影响)
.filter(site -> site.getParentSiteId() == null || site.getParentSiteId() == 0)
.map(SiteOapiController::toVo)
.collect(java.util.stream.Collectors.toList());
}
private static SiteOptionVo toVo(CmsSite site) {
SiteOptionVo vo = new SiteOptionVo();
vo.setSiteCode(site.getCode());
vo.setName(site.getName());
vo.setSchId(site.getSchId() == null ? null : site.getSchId().longValue());
return vo;
}
/** 站点选项(siteCode 为办事大厅侧定位键;schId 用于权限与租户定界) */
@Data
public static class SiteOptionVo {
private String siteCode;
private String name;
private Long schId;
}
}
@@ -13,6 +13,11 @@ import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.ViewCounterService;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,6 +26,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
@@ -30,6 +36,7 @@ import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -47,12 +54,15 @@ public class PortalController {
private static final int HOME_BLOCK_SIZE = 8;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
/** 指南页"相关事项"取 N 条(事项库 §5.3) */
private static final int RELATED_ITEMS_SIZE = 6;
private final PortalQueryService portalQueryService;
private final CmsArticleRepository articleRepository;
private final CmsSiteRepository siteRepository;
private final ViewCounterService viewCounterService;
private final PortalPageRenderer pageRenderer;
private final SvcItemQuery svcItemQuery;
@Value("${cms.portal.detail-max-age:300}")
private int detailMaxAge;
@@ -133,6 +143,8 @@ public class PortalController {
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", categoryId);
model.addAttribute("category", category);
// 栏目导航三件套(docs/11):面包屑全路径 + 顶导航祖先高亮 + 左侧兄弟侧栏
pageRenderer.attachCategoryNavigation(site, categoryId, model);
model.addAttribute("pageTitle", seoTitle(category, site));
model.addAttribute("seoDescription", category.getSeoDescription());
model.addAttribute("canonical", url.category(categoryId));
@@ -198,6 +210,8 @@ public class PortalController {
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
// 栏目导航三件套(docs/11):详情页同栏目页规则;栏目已删时 path 空,模板按缺省形态渲染
pageRenderer.attachCategoryNavigation(site, detail.getCategoryId(), model);
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
model.addAttribute("article", detail.withContent(
@@ -272,6 +286,416 @@ public class PortalController {
// ===== 内部装配 =====
/**
* 服务中心列表(事项库 §5.1/§5.2):三维筛选 obj/cat/dept + 关键词 q + 分页。
* 筛选/分页 URL 全部后端预生成(C-2:模板禁止手拼 query);service.html 为可选模板,
* 走 M0 整页回退(缺模板时视图名/themeBase/资产主题段统一退 default)。
*/
@GetMapping("/portal/{siteCode}/service")
public String service(@PathVariable("siteCode") String siteCode,
@RequestParam(value = "obj", required = false) String obj,
@RequestParam(value = "cat", required = false) String cat,
@RequestParam(value = "dept", required = false) Integer dept,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doServiceList(siteCode, obj, cat, dept, q, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/service */
@GetMapping("/portal/{p}/{s}/service")
public String serviceNested(@PathVariable("p") String p, @PathVariable("s") String s,
@RequestParam(value = "obj", required = false) String obj,
@RequestParam(value = "cat", required = false) String cat,
@RequestParam(value = "dept", required = false) Integer dept,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doServiceList(p + "/" + s, obj, cat, dept, q, page, request, response, model);
}
/** 事项指南页(§5.3):富文本已清洗(保存期 jsoup)+ 渲染期补基准;applyUrl 三态优先级 */
@GetMapping("/portal/{siteCode}/sv/{itemId}")
public String guide(@PathVariable("siteCode") String siteCode,
@PathVariable("itemId") Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doGuide(siteCode, itemId, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/sv/{itemId} */
@GetMapping("/portal/{p}/{s}/sv/{itemId}")
public String guideNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("itemId") Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doGuide(p + "/" + s, itemId, request, response, model);
}
private String doServiceList(String sitePath, String obj, String cat, Integer dept, String q,
Integer page, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "service", request, response);
if (site == null) {
return null;
}
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "service.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
applyServiceNavHighlight(site, url, model);
SvcItemPortalQuery query = new SvcItemPortalQuery();
query.setSiteId(site.getId());
query.setObjectKey(trimToNull(obj));
query.setCategoryKey(trimToNull(cat));
query.setDeptGroupId(dept);
query.setKeyword(trimToNull(q));
query.setPage(page == null || page < 1 ? 1 : page);
// P1-4:svc-hall 停机/重启窗口(连接类异常,含 DHHttpException code=0)降级空态渲染 200,
// 不再连坐访客 500;业务性缺失语义维持 §2.3 修复后的行为(portalPage 无 code=2 分支)
PortalServiceListData serviceList;
boolean svcUnavailable;
try {
serviceList = svcItemQuery.portalPage(query);
svcUnavailable = false;
} catch (RuntimeException e) {
log.warn("[portal] 事项服务不可达(降级空态渲染)siteId={}:{}", site.getId(), e.toString());
serviceList = new PortalServiceListData();
serviceList.setSiteId(site.getId());
serviceList.setPage(1);
serviceList.setPageSize(10);
serviceList.setTotal(0L);
serviceList.setTotalPages(0);
svcUnavailable = true;
}
model.addAttribute("svcUnavailable", svcUnavailable);
if (svcUnavailable) {
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
model.addAttribute("canonical", url.serviceList());
return "themes/" + effectiveTheme + "/service";
}
// 筛选/分页 URL 预生成(§5.4):勾选=替换本维、取消=移除本维,其余维度与关键词保留,page 归 1
Map<String, Object> currentFilters = currentServiceFilters(obj, cat, dept, q);
model.addAttribute("serviceFilters",
buildServiceFilters(site.getId(), url, obj, cat, dept, currentFilters));
Map<String, Object> activeFilters = new HashMap<>();
activeFilters.put("obj", trimToNull(obj));
activeFilters.put("cat", trimToNull(cat));
activeFilters.put("dept", dept);
activeFilters.put("q", q == null ? "" : q);
activeFilters.put("clearUrl", url.serviceList());
activeFilters.put("hasAny", trimToNull(obj) != null || trimToNull(cat) != null
|| dept != null || (q != null && !q.trim().isEmpty()));
model.addAttribute("activeFilters", activeFilters);
for (int p = 1; p <= serviceList.getTotalPages(); p++) {
Map<String, Object> pageQuery = new LinkedHashMap<>(currentFilters);
if (p > 1) {
pageQuery.put("page", p);
}
serviceList.getPageUrls().add(new PortalServiceListData.PageUrl(
p, url.serviceList(pageQuery), p == serviceList.getPage()));
}
model.addAttribute("serviceList", serviceList);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("q", q == null ? "" : q);
model.addAttribute("pageTitle", "服务中心 - " + site.getName());
model.addAttribute("seoDescription", null);
Map<String, Object> canonicalQuery = new LinkedHashMap<>(currentFilters);
if (serviceList.getPage() != null && serviceList.getPage() > 1) {
canonicalQuery.put("page", serviceList.getPage());
}
model.addAttribute("canonical", url.serviceList(canonicalQuery));
return "themes/" + effectiveTheme + "/service";
}
private String doGuide(String sitePath, Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "sv/" + itemId, request, response);
if (site == null) {
return null;
}
// P1-4:svc-hall 连接类异常降级空态渲染 200;业务性缺失(code=2→null)维持 404 语义
PortalServiceItem item;
boolean svcUnavailable;
try {
item = svcItemQuery.portalItem(itemId, site.getId());
svcUnavailable = false;
} catch (RuntimeException e) {
log.warn("[portal] 事项服务不可达(指南页降级空态渲染)siteId={} itemId={}:{}",
site.getId(), itemId, e.toString());
item = null;
svcUnavailable = true;
}
if (item == null && !svcUnavailable) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "guide.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
applyServiceNavHighlight(site, url, model);
model.addAttribute("svcUnavailable", svcUnavailable);
if (svcUnavailable) {
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
return "themes/" + effectiveTheme + "/guide";
}
try {
svcItemQuery.countView(itemId);
} catch (RuntimeException e) {
log.debug("[portal] 浏览量累加失败(忽略)itemId={}:{}", itemId, e.toString());
}
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
// 富文本渲染期补基准(copy-on-write,不污染缓存体;内容已在保存期过 jsoup 白名单)
model.addAttribute("item", item.withRewrittenHtml(
url.rewriteUploadUrls(item.getProcessHtml()),
url.rewriteUploadUrls(item.getMaterialHtml()),
url.rewriteUploadUrls(item.getNoticeHtml())));
model.addAttribute("attachments", attachmentsQuietly(itemId));
List<Map<String, Object>> related = new ArrayList<>();
for (PortalServiceItem rel : relatedQuietly(site.getId(), itemId, item.getCategoryKey())) {
Map<String, Object> row = new HashMap<>();
row.put("id", rel.getId());
row.put("title", rel.getTitle());
row.put("deptName", rel.getDeptName());
row.put("handleMode", rel.getHandleMode());
row.put("url", url.serviceItem(rel.getId()));
related.add(row);
}
model.addAttribute("relatedItems", related);
if (item.getFaqCategoryId() != null) {
model.addAttribute("faqCategoryUrl", url.category(item.getFaqCategoryId()));
}
if (item.getNoticeCategoryId() != null) {
model.addAttribute("noticeCategoryUrl", url.category(item.getNoticeCategoryId()));
}
if (item.getRuleCategoryId() != null) {
model.addAttribute("ruleCategoryUrl", url.category(item.getRuleCategoryId()));
}
// "我要办理"三态(§5.3):① ext_json.apply_url 手填外链(最高优先)
// ② 未填且站点配了 svc_hall_base 且事项开通在线办理(onlineFlag=1 且绑流程,§3.7——
// 与 svc-hall 侧 submit 校验"未开通在线办理"口径对齐,线下/纯指南事项不再渲染按钮)
// ③ 其余 → 不注入(模板不渲染按钮)
String applyUrl = item.getApplyUrl();
boolean onlineHandlable = item.getOnlineFlag() != null && item.getOnlineFlag() == 1
&& StringUtils.hasText(item.getFlowGuid());
if ((applyUrl == null || applyUrl.trim().isEmpty()) && onlineHandlable
&& site.getSvcHallBase() != null && !site.getSvcHallBase().trim().isEmpty()) {
String base = site.getSvcHallBase().trim();
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
applyUrl = base + "/#/apply?siteCode=" + urlEncodeValue(site.getCode())
+ "&itemCode=" + urlEncodeValue(item.getItemCode());
}
if (applyUrl != null && !applyUrl.trim().isEmpty()) {
model.addAttribute("applyUrl", applyUrl);
}
if (item.getCoverImage() != null && !item.getCoverImage().trim().isEmpty()) {
model.addAttribute("ogImage", url.upload(item.getCoverImage()));
}
model.addAttribute("pageTitle", item.getTitle() + " - " + site.getName());
model.addAttribute("seoDescription", item.getSummary());
model.addAttribute("canonical", url.serviceItem(itemId));
response.setHeader("Cache-Control", "max-age=" + detailMaxAge);
return "themes/" + effectiveTheme + "/guide";
}
/**
* 服务中心/指南页的顶导航高亮:服务中心页不是栏目页,currentCategoryId 为空会让
* 皮肤 header 的"首页"误高亮。若站点导航里存在指向本站服务中心列表页的外链栏目
* (type=3,如"服务中心"),则把它视为当前栏目——既有高亮逻辑即同时达成
* "首页不高亮 + 服务中心高亮";找不到匹配栏目时置 navServiceActive,
* 皮肤以该标记单独关闭"首页"高亮(无服务中心入口的站点保持原状)。
*/
private void applyServiceNavHighlight(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
String servicePath = pathOf(url.serviceList());
Integer matched = null;
if (servicePath != null) {
for (PortalCategory cat : site.getCategories()) {
if (isServiceNavLink(cat, servicePath)) {
matched = cat.getId();
break;
}
for (PortalCategory sub : cat.getChildren()) {
if (isServiceNavLink(sub, servicePath)) {
matched = sub.getId();
break;
}
}
if (matched != null) {
break;
}
}
}
if (matched != null) {
model.addAttribute("currentCategoryId", matched);
} else {
model.addAttribute("navServiceActive", true);
}
}
private boolean isServiceNavLink(PortalCategory cat, String servicePath) {
Integer type = cat.getType();
return type != null && type == CategoryTypeEnum.LINK.getCode()
&& servicePath.equals(pathOf(cat.getLinkUrl()));
}
/** 取 URL 的归一化路径:剥 scheme://host / query / fragment / 尾斜杠(相对链接原样返回路径) */
private static String pathOf(String url) {
if (url == null) {
return null;
}
String u = url.trim();
int hash = u.indexOf('#');
if (hash >= 0) {
u = u.substring(0, hash);
}
int query = u.indexOf('?');
if (query >= 0) {
u = u.substring(0, query);
}
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
int scheme = u.indexOf("://");
if (scheme >= 0) {
int slash = u.indexOf('/', scheme + 3);
u = slash >= 0 ? u.substring(slash) : "";
}
return u.isEmpty() ? null : u;
}
/** 门户页面公共骨架属性(P1-4 降级路径与正常路径共用;q/seoDescription 等页面特有属性由调用方补) */
private void addChromeAttributes(Model model, PortalSiteSnapshot site, HttpServletRequest request,
String effectiveTheme, PortalUrlHelper url, String pageTitle) {
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("pageTitle", pageTitle);
}
/** 附件查询静默降级(svc-hall 抖动不阻断已可渲染的指南页;业务性缺失已在查询层转空) */
private List<com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo> attachmentsQuietly(Integer itemId) {
try {
return svcItemQuery.attachments(itemId);
} catch (RuntimeException e) {
log.warn("[portal] 附件查询失败(降级为空)itemId={}:{}", itemId, e.toString());
return new ArrayList<>();
}
}
/** 相关事项查询静默降级(同上) */
private List<PortalServiceItem> relatedQuietly(Integer siteId, Integer itemId, String categoryKey) {
try {
return svcItemQuery.relatedItems(siteId, itemId, categoryKey, RELATED_ITEMS_SIZE);
} catch (RuntimeException e) {
log.warn("[portal] 相关事项查询失败(降级为空)itemId={}:{}", itemId, e.toString());
return new ArrayList<>();
}
}
/** 当前有效筛选参数(obj/cat/dept/q,空值剔除)——分页与 canonical URL 的公共底稿 */
private Map<String, Object> currentServiceFilters(String obj, String cat, Integer dept, String q) {
Map<String, Object> filters = new LinkedHashMap<>();
if (trimToNull(obj) != null) {
filters.put("obj", obj.trim());
}
if (trimToNull(cat) != null) {
filters.put("cat", cat.trim());
}
if (dept != null) {
filters.put("dept", dept);
}
if (q != null && !q.trim().isEmpty()) {
filters.put("q", q.trim());
}
return filters;
}
/** 三组筛选项 → 模板 Facet({code|id, name, count, active, url},url 直用于 th:href) */
private Map<String, Object> buildServiceFilters(Integer siteId, PortalUrlHelper url,
String obj, String cat, Integer dept,
Map<String, Object> currentFilters) {
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
Map<String, Object> model = new HashMap<>();
List<PortalServiceFilters.Facet> objects = new ArrayList<>();
for (PortalServiceFilters.Option option : filters.getObjects()) {
boolean active = option.getKey().equals(trimToNull(obj));
objects.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "obj", active ? null : option.getKey()))));
}
List<PortalServiceFilters.Facet> categories = new ArrayList<>();
for (PortalServiceFilters.Option option : filters.getCategories()) {
boolean active = option.getKey().equals(trimToNull(cat));
categories.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "cat", active ? null : option.getKey()))));
}
List<PortalServiceFilters.Facet> depts = new ArrayList<>();
for (PortalServiceFilters.DeptOption option : filters.getDepts()) {
boolean active = option.getId() != null && option.getId().equals(dept);
depts.add(PortalServiceFilters.Facet.ofId(option.getId(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "dept",
active ? null : String.valueOf(option.getId())))));
}
model.put("objects", objects);
model.put("categories", categories);
model.put("depts", depts);
return model;
}
/** 复制当前筛选并替换/移除某一维(其余维度保留,page 永不进入——选项切换即归第 1 页) */
private Map<String, Object> toggleFilter(Map<String, Object> current, String key, String value) {
Map<String, Object> next = new LinkedHashMap<>(current);
next.remove("page");
if (value == null) {
next.remove(key);
} else {
next.put(key, value);
}
return next;
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static String urlEncodeValue(String value) {
try {
return java.net.URLEncoder.encode(value == null ? "" : value, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return value;
}
}
/**
* 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
@@ -286,7 +710,7 @@ public class PortalController {
return site;
}
if (sitePath != null && !sitePath.contains("/")) {
CmsSite legacy = siteRepository.findByCode(sitePath).orElse(null);
CmsSite legacy = siteRepository.findFirstByCodeOrderByIdAsc(sitePath).orElse(null);
if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty()
&& !legacy.getPath().equals(sitePath)) {
StringBuilder location = new StringBuilder(request.getContextPath())
@@ -316,13 +740,23 @@ public class PortalController {
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
return buildUrl(site, request, null);
}
/**
* M0 可选模板整页回退配套(事项库 §4.2):effectiveTheme 非空时以它构造 URL 生成器——
* 资产 URL 主题段(asset())必须与视图名/themeBase 同主题,否则 default 的 HTML 配别家皮肤的 CSS。
* 三级基准决策不变(public_base_url → base-origin → 当前请求派生)。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request, String effectiveTheme) {
String theme = effectiveTheme == null ? site.getTheme() : effectiveTheme;
if (site.getPublicBaseUrl() != null) {
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), site.getTheme(), baseOrigin);
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), theme, baseOrigin);
}
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), site.getTheme());
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), theme);
}
return PortalUrlHelper.fromRequest(request, site.getPath(), site.getTheme());
return PortalUrlHelper.fromRequest(request, site.getPath(), theme);
}
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
@@ -356,6 +790,7 @@ public class PortalController {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -5,10 +5,13 @@ import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSideNav;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.theme.ThemeRegistry;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
@@ -19,23 +22,49 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PortalPageRenderer {
/** 首页每个一级栏目取 N 条 */
/** 首页每个一级栏目取 N 条(docs/12:双栏新闻卡形态需 16 条铺满 8 行 × 2 列) */
private static final int HOME_BLOCK_SIZE = 8;
private static final int HOME_BLOCK_GRID_SIZE = 16;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
private final CmsArticleRepository articleRepository;
private final ThemeRegistry themeRegistry;
/** docs/20 D-7:people 区块数据源 */
private final com.yunhai.cms.repository.CmsPersonRepository personRepository;
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */
/**
* M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html
* 这类 REQUIRED_TEMPLATES 之外的可选页)时,整页回退 default,而非仅换模板。
* 返回的 effectiveTheme 必须同时用于视图名、themeBase 与 URL 生成器(asset() 的
* 资产主题段)——只换模板不换主题 = default 的 HTML 配别家皮肤的 CSS。
* 必备四页由 ThemePackageValidator 注册期保证,不走本判定。
*/
public String resolveEffectiveTheme(PortalSiteSnapshot site, String requiredTemplate) {
String theme = site.getTheme();
if (themeRegistry.resource(theme, requiredTemplate).exists()) {
return theme;
}
log.warn("主题 {} 缺可选模板 {}(site={}),整页回退 {}", theme, requiredTemplate,
site.getCode(), ThemeRegistry.DEFAULT_THEME);
return ThemeRegistry.DEFAULT_THEME;
}
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
* 原样回落现状装配(D2 兼容,老站一个像素都不变)。 */
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
model.addAttribute("url", url);
model.addAttribute("site", site);
@@ -46,9 +75,20 @@ public class PortalPageRenderer {
model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url);
model.addAttribute("banners", banners);
model.addAttribute("topArticles", topArticles(site.getId()));
List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
if (layoutBlocks != null) {
// 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
model.addAttribute("layoutBlocks", layoutBlocks);
model.addAttribute("galleryBanners", new ArrayList<>());
model.addAttribute("topArticles", new ArrayList<>());
model.addAttribute("focusArticles", new ArrayList<>());
model.addAttribute("categoryBlocks", new ArrayList<>());
} else {
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId(), HOME_TOP_SIZE));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
}
model.addAttribute("pageTitle", site.getName());
model.addAttribute("seoDescription", site.getConfig() == null ? null
: site.getConfig().get("seo_description"));
@@ -63,6 +103,71 @@ public class PortalPageRenderer {
// ===== 内部装配(自 PortalController 原样迁移)=====
/**
* 栏目导航装配(docs/11):栏目页/详情页共用,三条模型属性——
* categoryPath(顶级→当前路径,面包屑全路径)、navTopId(顶导航祖先高亮)、
* sideNav(模块有二级子栏目时的左侧兄弟导航,无则不挂,模板 th:if 兜底)。
* 全部基于站点快照树推导,零新增查询;搜索/首页等无栏目上下文的路由不调用。
*/
public void attachCategoryNavigation(PortalSiteSnapshot site, Integer currentCategoryId, Model model) {
if (currentCategoryId == null) {
return;
}
List<PortalCategory> path = pathFromRoot(site.getCategories(), currentCategoryId);
model.addAttribute("categoryPath", path);
if (path.isEmpty()) {
return; // 栏目已删等兜底(详情页可能挂已删栏目文章),模板按缺省形态渲染
}
PortalCategory top = path.get(0);
model.addAttribute("navTopId", top.getId());
if (!top.getChildren().isEmpty()) {
PortalSideNav nav = new PortalSideNav();
nav.setTop(top);
nav.setItems(top.getChildren());
nav.setActiveId(resolveActiveId(path, currentCategoryId));
model.addAttribute("sideNav", nav);
}
}
/**
* 侧栏高亮位:当前为二级=当前栏目;一级直点=null(侧栏头即模块名,无高亮项);
* 三级及更深=所在二级分支(决策点②:固定列二级层,高亮当前所属分支)。
*/
private Integer resolveActiveId(List<PortalCategory> path, Integer currentCategoryId) {
if (path.size() == 2) {
return currentCategoryId;
}
return path.size() > 2 ? path.get(1).getId() : null;
}
/** 自栏目树求 根→目标 路径(含两端);目标不存在返回空表 */
private List<PortalCategory> pathFromRoot(List<PortalCategory> roots, Integer categoryId) {
for (PortalCategory root : roots) {
List<PortalCategory> hit = pathFromRootDeep(root, categoryId);
if (hit != null) {
return hit;
}
}
return new ArrayList<>();
}
private List<PortalCategory> pathFromRootDeep(PortalCategory node, Integer categoryId) {
if (node.getId().equals(categoryId)) {
List<PortalCategory> path = new ArrayList<>();
path.add(node);
return path;
}
for (PortalCategory child : node.getChildren()) {
List<PortalCategory> sub = pathFromRootDeep(child, categoryId);
if (sub != null) {
sub.add(0, node);
return sub;
}
}
return null;
}
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
PortalUrlHelper url) {
@@ -101,10 +206,10 @@ public class PortalPageRenderer {
}
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
private List<PortalListData.Item> topArticles(Integer siteId) {
private List<PortalListData.Item> topArticles(Integer siteId, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_TOP_SIZE,
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
@@ -137,6 +242,7 @@ public class PortalPageRenderer {
/**
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
* docs/12:区块带出栏目 homeStyle,双栏新闻卡(news_grid)取 16 条铺满双栏,其余取 8 条。
*/
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
PortalUrlHelper url) {
@@ -145,12 +251,14 @@ public class PortalPageRenderer {
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
continue;
}
int blockSize = "news_grid".equals(category.getHomeStyle())
? HOME_BLOCK_GRID_SIZE : HOME_BLOCK_SIZE;
List<Integer> categoryIds = new ArrayList<>();
collectListDescendantIds(category, categoryIds);
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_BLOCK_SIZE,
PageRequest.of(0, blockSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
@@ -159,6 +267,7 @@ public class PortalPageRenderer {
}
Map<String, Object> block = new HashMap<>();
block.put("category", category);
block.put("homeStyle", category.getHomeStyle());
block.put("items", items);
block.put("moreUrl", url.category(category.getId()));
blocks.add(block);
@@ -176,12 +285,542 @@ public class PortalPageRenderer {
}
}
// ===== 首页布局装配(docs/16 主线B §4.4)=====
/** 布局区块条数上限与缺省(与编辑器/服务端校验同约束) */
private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/** 区块内手填链接:http(s) 绝对地址或站内根相对路径(与 SiteService 保存期校验同契约,拒绝伪协议) */
private static final Pattern LAYOUT_LINK_URL = Pattern.compile("^(https?://\\S+|/.+)$");
/**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
* 每块输出与 categoryBlocks 同构的 Map(category.name/homeStyle 供主题 fragment 兼容消费),
* 另带 type/style/title/subtitle/background/source 布局属性;visible=false 的块保序跳过。
*/
private List<Map<String, Object>> buildLayoutBlocks(PortalSiteSnapshot site, Map<String, Object> homeLayout,
PortalUrlHelper url) {
if (homeLayout == null) {
return null;
}
Object raw = homeLayout.get("blocks");
if (!(raw instanceof List)) {
return null; // 容错:blocks 非数组视为无布局
}
Set<Integer> treeIds = new java.util.HashSet<>();
collectTreeIds(site.getCategories(), treeIds);
List<Map<String, Object>> result = new ArrayList<>();
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<String, Object> spec = (Map<String, Object>) o;
if (Boolean.FALSE.equals(spec.get("visible"))) {
continue; // 编辑器「隐藏」:保序下线
}
Map<String, Object> block = buildLayoutBlock(site, spec, treeIds, url);
if (block != null) {
result.add(block);
}
}
return result;
}
private void collectTreeIds(List<PortalCategory> categories, Set<Integer> ids) {
for (PortalCategory c : categories) {
ids.add(c.getId());
collectTreeIds(c.getChildren(), ids);
}
}
private Map<String, Object> buildLayoutBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
String type = spec.get("type") == null ? "" : String.valueOf(spec.get("type"));
switch (type) {
case "article_list":
return buildArticleListBlock(site, spec, treeIds, url);
case "ticker":
return buildTickerBlock(site, spec, treeIds);
case "gallery":
return buildGalleryBlock(site, spec, url);
case "links":
return buildLinksBlock(site, spec);
case "quick_links":
return buildQuickLinksBlock(spec);
case "notice_bar":
return buildNoticeBarBlock(spec);
// ===== docs/20 D-1~D-9 新组件(空数据返回 null = 整块不渲染,R-44)=====
case "stats_band":
return buildStatsBandBlock(spec);
case "featured_topics":
return buildFeaturedTopicsBlock(site, spec, url);
case "video_tour":
return buildVideoTourBlock(spec, url);
case "events":
return buildEventsBlock(site, spec, url);
case "people":
return buildPeopleBlock(site, spec, treeIds);
case "badges":
return buildBadgesBlock(spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
}
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题)。
* F2(docs/20):透传区块 id(编辑器实时预览 postMessage 重排的 DOM 锚点 data-plb-id)。
* D-4(docs/20):subtitle 全类型通用(中英双语栏目标题——主题 titleStyle 参数控制展示开关)。 */
private Map<String, Object> baseBlock(Map<String, Object> spec, String type, String fallbackTitle) {
Map<String, Object> block = new HashMap<>();
block.put("type", type);
if (spec.get("id") != null) {
block.put("id", String.valueOf(spec.get("id")));
}
String title = spec.get("title") == null ? "" : String.valueOf(spec.get("title")).trim();
if (title.isEmpty()) {
title = fallbackTitle;
}
block.put("title", title);
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (!subtitle.isEmpty()) {
block.put("subtitle", subtitle);
}
Map<String, Object> category = new HashMap<>();
category.put("name", title);
block.put("category", category);
return block;
}
/**
* 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入),
* 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。
*/
private Map<String, Object> buildArticleListBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
List<Integer> categoryIds = new ArrayList<>();
PortalCategory first = null;
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node == null) {
continue;
}
collectListDescendantIds(node, categoryIds);
if (first == null) {
first = node;
}
}
int size = clampSize(spec.get("size"), LAYOUT_LIST_SIZE_DEFAULT);
List<PortalListData.Item> items = categoryIds.isEmpty()
? new ArrayList<>()
: articlesIn(site.getId(), categoryIds, size);
String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style"));
Map<String, Object> block = baseBlock(spec, "article_list",
first != null ? first.getName() : "栏目文章");
block.put("style", style);
block.put("homeStyle", style); // 主题 fragment 兼容:cg-newshot 等按 homeStyle 分支
block.put("items", items);
block.put("size", size);
block.put("moreUrl", first != null ? url.category(first.getId()) : url.home());
// 背景大图(§4.5):区块未设 background 时以旧键 news_bg_image 为默认值(存量配置无损生效)
boolean background = spec.get("background") != null
? Boolean.parseBoolean(String.valueOf(spec.get("background")))
: hasText(configValue(site, "news_bg_image"));
block.put("background", background);
return block;
}
/** 滚动速递:source=top 走全站置顶;source=category 走栏目 IN 查询(树外栏目跳过) */
private Map<String, Object> buildTickerBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source"));
int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT);
List<PortalListData.Item> items;
if ("category".equals(source)) {
List<Integer> categoryIds = new ArrayList<>();
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node != null) {
collectListDescendantIds(node, categoryIds);
}
}
items = categoryIds.isEmpty() ? new ArrayList<>() : articlesIn(site.getId(), categoryIds, size);
} else {
items = topArticles(site.getId(), size);
}
Map<String, Object> block = baseBlock(spec, "ticker", "要闻速递");
block.put("source", source);
block.put("items", items);
block.put("size", size);
return block;
}
/**
* 图片画廊:数据通道不变(轮播位 gallery,轮播管理维护)。
* 标题/副题(§4.5):区块未设时以旧键 gallery_title/gallery_subtitle 为默认值;
* size 未设 = 全部展示(历史站点画廊数量不定,不加隐式上限)。
*/
private Map<String, Object> buildGalleryBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
Map<String, Object> config = site.getConfig() == null ? new HashMap<>() : site.getConfig();
Map<String, Object> block = baseBlock(spec, "gallery", str(config.get("gallery_title"), "线上展厅"));
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (subtitle.isEmpty()) {
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
}
if (!subtitle.isEmpty()) {
block.put("subtitle", subtitle);
}
// D-8(docs/20):campus_wall 布局开关(grid 规整网格 / masonry 瀑布流)
block.put("layout", "masonry".equals(str(spec.get("layout"), "")) ? "masonry" : "grid");
block.put("items", activeBanners(site, "gallery", url));
return block;
}
/** 链接组:友情链接分组(group 未设 = 全部分组;首页此前未消费该数据,现有通道直出) */
private Map<String, Object> buildLinksBlock(PortalSiteSnapshot site, Map<String, Object> spec) {
String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim();
List<com.yunhai.cms.model.portal.PortalLinkGroup> groups = new ArrayList<>();
for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) {
if (group.isEmpty() || group.equals(g.getGroupName())) {
groups.add(g);
}
}
Map<String, Object> block = baseBlock(spec, "links", "友情链接");
block.put("group", group);
block.put("linkGroups", groups);
return block;
}
/**
* 快捷入口宫格(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):数据是区块内联数组,不读任何外部表。
* 保存期已校验 url/icon 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。
*/
private Map<String, Object> buildQuickLinksBlock(Map<String, Object> spec) {
List<Map<String, Object>> links = new ArrayList<>();
Object raw = spec.get("items");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String name = str(item.get("name"), "").trim();
String linkUrl = str(item.get("url"), "").trim();
if (name.isEmpty() || !LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
continue;
}
Map<String, Object> link = new HashMap<>();
link.put("name", name);
link.put("url", linkUrl);
// D-2:可选图标标识符(未选时骨架渲染首字徽章兜底)
String icon = str(item.get("icon"), "").trim();
if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) {
link.put("icon", icon);
}
links.add(link);
}
}
Map<String, Object> block = baseBlock(spec, "quick_links", "快捷通道");
block.put("quickLinks", links);
return block;
}
/** 公告条(v2 手册 §2.8 类型 B):站点单条手填通告;level 白名单外归一 info,url 不合法则退化为纯文本 */
private Map<String, Object> buildNoticeBarBlock(Map<String, Object> spec) {
String linkUrl = str(spec.get("url"), "").trim();
if (!LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
linkUrl = "";
}
Map<String, Object> block = baseBlock(spec, "notice_bar", "公告");
block.put("noticeText", str(spec.get("text"), "").trim());
block.put("noticeUrl", linkUrl);
block.put("noticeLevel", "warn".equals(str(spec.get("level"), "").trim()) ? "warn" : "info");
return block;
}
// ===== docs/20 D 新组件装配(空数据返回 null → buildLayoutBlocks 跳过 = 整块不渲染,R-44)=====
/** stats_band(D-1 数字计数带):stats 内联数组透传(骨架共享 count-up 动画);空/无有效项 → 不渲染 */
private Map<String, Object> buildStatsBandBlock(Map<String, Object> spec) {
List<Map<String, Object>> stats = new ArrayList<>();
Object raw = spec.get("stats");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String label = str(item.get("label"), "").trim();
String value = str(item.get("value"), "").trim();
if (label.isEmpty() || value.isEmpty()) {
continue;
}
Map<String, Object> stat = new HashMap<>();
stat.put("label", label);
stat.put("value", value);
String unit = str(item.get("unit"), "").trim();
if (!unit.isEmpty()) {
stat.put("unit", unit);
}
String icon = str(item.get("icon"), "").trim();
if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) {
stat.put("icon", icon);
}
stats.add(stat);
}
}
if (stats.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "stats_band", "数说学校");
block.put("stats", stats);
return block;
}
/** featured_topics(D-3 专题大卡):数据 = banner 表 topic 展示位(描述/CTA 扩展列);无在展数据 → 不渲染 */
private Map<String, Object> buildFeaturedTopicsBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
List<Map<String, Object>> items = new ArrayList<>();
for (PortalBanner b : site.getBanners()) {
if (!"topic".equals(b.getPosition()) || !isActiveNow(b)) {
continue;
}
Map<String, Object> item = new HashMap<>();
item.put("title", b.getTitle());
item.put("imageUrl", url.upload(b.getImageUrl()));
item.put("linkUrl", resolveBannerLink(b, url));
if (hasText(b.getDescription())) {
item.put("description", b.getDescription());
}
if (hasText(b.getCtaText())) {
item.put("ctaText", b.getCtaText());
}
if (hasText(b.getCtaUrl())) {
item.put("ctaUrl", b.getCtaUrl());
}
items.add(item);
}
if (items.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "featured_topics", "专题推荐");
block.put("items", items);
return block;
}
/** video_tour(D-5 视频/VR 校园):cover 资产补基准 + url + title;脏数据(缺 cover/url)→ 不渲染 */
private Map<String, Object> buildVideoTourBlock(Map<String, Object> spec, PortalUrlHelper url) {
String cover = str(spec.get("cover"), "").trim();
String videoUrl = str(spec.get("url"), "").trim();
if (!ASSET_URL_PATTERN.matcher(cover).matches() || !LAYOUT_LINK_URL.matcher(videoUrl).matches()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "video_tour", "云端校园");
block.put("cover", url.upload(cover));
block.put("videoUrl", videoUrl);
return block;
}
/** events(D-6 学术日历/活动):数据 = banner 表 event 展示位(event_date/location 扩展列);无在展数据 → 不渲染 */
private Map<String, Object> buildEventsBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
List<PortalBanner> events = new ArrayList<>();
for (PortalBanner b : site.getBanners()) {
if ("event".equals(b.getPosition()) && isActiveNow(b)) {
events.add(b);
}
}
// 活动日历语义:按活动日期升序(无日期的排后按 sortNo),最多 6 条
events.sort(java.util.Comparator
.comparing((PortalBanner b) -> b.getEventDate() == null ? java.time.LocalDateTime.MAX : b.getEventDate())
.thenComparing(PortalBanner::getSortNo, java.util.Comparator.nullsLast(Integer::compareTo)));
if (events.size() > 6) {
events = events.subList(0, 6);
}
if (events.isEmpty()) {
return null;
}
List<Map<String, Object>> items = new ArrayList<>();
for (PortalBanner b : events) {
Map<String, Object> item = new HashMap<>();
item.put("title", b.getTitle());
item.put("linkUrl", resolveBannerLink(b, url));
if (b.getEventDate() != null) {
item.put("eventDate", b.getEventDate());
}
if (hasText(b.getLocation())) {
item.put("location", b.getLocation());
}
items.add(item);
}
Map<String, Object> block = baseBlock(spec, "events", "学术活动");
block.put("items", items);
return block;
}
/** people(D-7 名师风采):cms_person 启用数据(categoryId 可选过滤,须在本站栏目树内);无数据 → 不渲染 */
private Map<String, Object> buildPeopleBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
Integer categoryId = null;
Object rawCategory = spec.get("categoryId");
if (rawCategory instanceof Number && ((Number) rawCategory).intValue() > 0
&& treeIds.contains(((Number) rawCategory).intValue())) {
categoryId = ((Number) rawCategory).intValue();
}
List<com.yunhai.cms.entity.CmsPerson> people = new ArrayList<>();
for (com.yunhai.cms.entity.CmsPerson p
: personRepository.findBySiteIdAndStatusOrderBySortNoAscIdAsc(site.getId(), 1)) {
if (categoryId != null && !categoryId.equals(p.getCategoryId())) {
continue;
}
people.add(p);
if (people.size() >= 12) {
break;
}
}
if (people.isEmpty()) {
return null;
}
List<Map<String, Object>> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsPerson p : people) {
Map<String, Object> item = new HashMap<>();
item.put("name", p.getName());
if (hasText(p.getTitle())) {
item.put("title", p.getTitle());
}
if (hasText(p.getBio())) {
item.put("bio", p.getBio());
}
if (hasText(p.getAvatar())) {
item.put("avatar", p.getAvatar());
}
items.add(item);
}
Map<String, Object> block = baseBlock(spec, "people", "名师风采");
block.put("items", items);
return block;
}
/** badges(D-9 排名/学科评估徽章带):badgeItems 内联数组透传;空 → 不渲染 */
private Map<String, Object> buildBadgesBlock(Map<String, Object> spec) {
List<Map<String, Object>> items = new ArrayList<>();
Object raw = spec.get("badgeItems");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> m = (Map<?, ?>) o;
String label = str(m.get("label"), "").trim();
String image = str(m.get("image"), "").trim();
if (label.isEmpty() || !ASSET_URL_PATTERN.matcher(image).matches()) {
continue;
}
Map<String, Object> item = new HashMap<>();
item.put("label", label);
item.put("image", image);
String desc = str(m.get("desc"), "").trim();
if (!desc.isEmpty()) {
item.put("desc", desc);
}
items.add(item);
}
}
if (items.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "badges", "实力声誉");
block.put("items", items);
return block;
}
/** banner 时间窗过滤(activeBanners 同语义,供 topic/event 位复用;快照内 banner 已过滤启用态) */
private boolean isActiveNow(PortalBanner b) {
java.time.LocalDateTime now = java.time.LocalDateTime.now();
return (b.getStartTime() == null || !now.isBefore(b.getStartTime()))
&& (b.getEndTime() == null || now.isBefore(b.getEndTime()));
}
// ===== 布局装配工具 =====
/** 区块内图片资产 URL 形态(与 SiteService ASSET_PATTERN 同语义) */
private static final Pattern ASSET_URL_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$");
private static String str(Object v, String fallback) {
return v == null ? fallback : String.valueOf(v);
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private static String configValue(PortalSiteSnapshot site, String key) {
Object v = site.getConfig() == null ? null : site.getConfig().get(key);
return v == null ? null : String.valueOf(v);
}
private static List<Integer> intList(Object v) {
List<Integer> ids = new ArrayList<>();
if (v instanceof List) {
for (Object o : (List<?>) v) {
if (o instanceof Number && ((Number) o).intValue() > 0) {
ids.add(((Number) o).intValue());
}
}
}
return ids;
}
private static int clampSize(Object v, int fallback) {
if (!(v instanceof Number)) {
return fallback;
}
int n = ((Number) v).intValue();
return Math.max(1, Math.min(LAYOUT_SIZE_MAX, n));
}
private List<PortalListData.Item> articlesIn(Integer siteId, List<Integer> categoryIds, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */
private PortalCategory findInTree(List<PortalCategory> categories, Integer id) {
for (PortalCategory c : categories) {
if (c.getId().equals(id)) {
return c;
}
PortalCategory hit = findInTree(c.getChildren(), id);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -59,4 +59,27 @@ public class CmsBanner extends BaseEntity {
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
// ===== docs/20 D-3/D-6:banner 表扩展(topic=专题大卡位 / event=学术活动位)=====
// 仅对应展示位消费;home/gallery 位忽略。可空列,ddl-auto=update 自动补列(SQL 脚本另附 resources/sql)。
/** 专题大卡描述文案(position=topic) */
@Column(name = "description", length = 500)
private String description;
/** 专题大卡 CTA 按钮文字(position=topic,如「进入专题」) */
@Column(name = "cta_text", length = 100)
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
@Column(name = "cta_url", length = 500)
private String ctaUrl;
/** 活动日期(position=event,学术日历大日期块) */
@Column(name = "event_date")
private LocalDateTime eventDate;
/** 活动地点(position=event) */
@Column(name = "location", length = 200)
private String location;
}
@@ -42,6 +42,13 @@ public class CmsCategory extends BaseEntity {
@Column(name = "template", length = 64)
private String template;
/**
* 首页展示形态(docs/11,空=主题默认卡):news_grid=双栏新闻卡、media_focus=来源标签列表。
* 仅一级列表型栏目在首页生效;旧主题不消费该字段(仅条数外溢 16)。
*/
@Column(name = "home_style", length = 32)
private String homeStyle;
/**
* 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。
* 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。
@@ -0,0 +1,56 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 名师风采人物(docs/20 D-7:首页 people 区块数据源,新表)。
* 人物按站点隔离、可挂栏目分组(category_id 可空 = 未分组);头像走附件上传通道。
* DDL:hibernate ddl-auto=update 自动建表;DBA 审阅脚本见 resources/sql/cms_person.sql(不自动执行)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_person", indexes = {
@Index(name = "idx_person_site", columnList = "site_id, status, sort_no"),
@Index(name = "idx_person_category", columnList = "category_id")
})
public class CmsPerson extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 姓名 */
@Column(name = "name", nullable = false, length = 100)
private String name;
/** 头衔/职称(如 教授、长江学者) */
@Column(name = "title", length = 100)
private String title;
/** 一句话简介 */
@Column(name = "bio", length = 500)
private String bio;
/** 头像 URL(附件上传,/cms/upload/** 或 http(s)) */
@Column(name = "avatar", length = 500)
private String avatar;
/** 所属栏目外键(可空 = 未分组;people 区块按此过滤) */
@Column(name = "category_id")
private Integer categoryId;
/** 同级排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
}
@@ -44,6 +44,9 @@ public class PortalCacheEvictListener {
clear(CacheNames.LIST);
}
// 事项域缓存(cms:svcItem):V2.0 拆分后无本地写事件,remote 模式靠 TTL 过期
// (办事大厅变更后最多滞后 5min);ServiceItemChangedEvent 已随事项管理能力迁出。
private void evictArticle(Integer articleId) {
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
if (cache != null && articleId != null) {
@@ -8,7 +8,7 @@ import javax.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 轮播保存(校验 link_type/link_value,方案 §9.2)。
* 轮播保存(校验 link_type/link_value,方案 §9.2;docs/20 D-3/D-6 扩展 topic/event 位字段)。
*/
@Data
public class BannerSaveRequest {
@@ -49,4 +49,25 @@ public class BannerSaveRequest {
/** 0=停用 1=启用 */
private Integer status = 1;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展(仅对应位消费,其余位忽略)=====
/** 专题大卡描述(position=topic) */
@Size(max = 500, message = "专题描述过长")
private String description;
/** 专题大卡 CTA 按钮文字(position=topic) */
@Size(max = 100, message = "按钮文字过长")
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
@Size(max = 500, message = "按钮地址过长")
private String ctaUrl;
/** 活动日期(position=event) */
private LocalDateTime eventDate;
/** 活动地点(position=event) */
@Size(max = 200, message = "活动地点过长")
private String location;
}
@@ -38,6 +38,10 @@ public class CategorySaveRequest {
@Size(max = 64)
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus,服务端白名单校验) */
@Size(max = 32)
private String homeStyle;
/** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */
private String content;
@@ -0,0 +1,42 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 名师风采人物保存(docs/20 D-7)。
*/
@Data
public class PersonSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
@NotBlank(message = "姓名不得为空")
@Size(max = 100, message = "姓名过长")
private String name;
@Size(max = 100, message = "头衔过长")
private String title;
@Size(max = 500, message = "简介过长")
private String bio;
/** 头像(/cms/upload/** 或 http(s);可空 = 首字徽章兜底) */
@Size(max = 500)
private String avatar;
/** 所属栏目(可空 = 未分组;须属于本站点) */
private Integer categoryId;
private Integer sortNo = 0;
/** 0=停用 1=启用 */
private Integer status = 1;
}
@@ -44,9 +44,12 @@ public class SiteSaveRequest {
private String configJson;
/**
* 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key;
* 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。
* 主题微调参数(方案 §5.3)。
*
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
*/
@Deprecated
private Map<String, String> themeParams;
/** 0=停用 1=启用 */
@@ -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;
}
@@ -4,13 +4,23 @@ import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** 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;
/**
* 包级参数值(工作项 T:参数微调迁皮肤包级):null=不改,非 null=整体替换(空 Map=恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃;内置包与外部包统一写 .meta 覆盖层。
*/
private Map<String, String> paramValues;
}
@@ -34,6 +34,22 @@ public class PortalBanner {
/** 下线时间(渲染方过滤) */
private LocalDateTime endTime;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
/** 专题大卡描述(position=topic) */
private String description;
/** 专题大卡 CTA 按钮文字(position=topic) */
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
private String ctaUrl;
/** 活动日期(position=event,学术日历大日期块) */
private LocalDateTime eventDate;
/** 活动地点(position=event) */
private String location;
public static PortalBanner of(CmsBanner b) {
PortalBanner item = new PortalBanner();
item.setId(b.getId());
@@ -45,6 +61,11 @@ public class PortalBanner {
item.setSortNo(b.getSortNo());
item.setStartTime(b.getStartTime());
item.setEndTime(b.getEndTime());
item.setDescription(b.getDescription());
item.setCtaText(b.getCtaText());
item.setCtaUrl(b.getCtaUrl());
item.setEventDate(b.getEventDate());
item.setLocation(b.getLocation());
return item;
}
}
@@ -25,6 +25,9 @@ public class PortalCategory {
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
private String homeStyle;
private String seoTitle;
private String seoDescription;
@@ -42,6 +45,7 @@ public class PortalCategory {
node.setType(c.getType());
node.setLinkUrl(c.getLinkUrl());
node.setTemplate(c.getTemplate());
node.setHomeStyle(c.getHomeStyle());
node.setSeoTitle(c.getSeoTitle());
node.setSeoDescription(c.getSeoDescription());
node.setContent(c.getContent());
@@ -60,6 +64,7 @@ public class PortalCategory {
copy.type = type;
copy.linkUrl = linkUrl;
copy.template = template;
copy.homeStyle = homeStyle;
copy.seoTitle = seoTitle;
copy.seoDescription = seoDescription;
copy.content = content;
@@ -37,6 +37,9 @@ public class PortalListData {
private String coverImage;
/** 来源(转载出处,docs/11:媒体聚焦来源徽标用;可空) */
private String source;
private LocalDateTime publishTime;
private Integer topmost;
@@ -0,0 +1,23 @@
package com.yunhai.cms.model.portal;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 门户左侧栏目导航(docs/11):模块存在二级子栏目时,栏目页/详情页左侧
* 展示"模块名 + 全部二级子栏目",当前位高亮。纯展示模型,由站点快照树推导。
*/
@Data
public class PortalSideNav {
/** 顶级模块(侧栏头) */
private PortalCategory top;
/** 全部二级子栏目(快照序,列表/单页/外链型均列出) */
private List<PortalCategory> items = new ArrayList<>();
/** 当前高亮栏目 id:当前为二级时=当前栏目;三级及更深时=所在二级分支;一级直点=null */
private Integer activeId;
}
@@ -42,6 +42,12 @@ public class PortalSiteSnapshot {
/** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */
private String publicBaseUrl;
/**
* 服务大厅基地址(config_json.svc_hall_base,事项库 §3.7):非空时指南页"我要办理"
* 按其生成 svhall 发起页 URL(优先级低于事项手填 ext_json.apply_url);空=不生成。
*/
private String svcHallBase;
/** 启用栏目树(status=1,已组树) */
private List<PortalCategory> categories = new ArrayList<>();
@@ -63,6 +69,19 @@ public class PortalSiteSnapshot {
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
private String customCss = "";
/**
* 首页布局(docs/16 主线B:config_json.home_layout,{version, blocks:[…]})。
* null = 未编排布局(渲染端回落「全部栏目自动渲染」现状,老站零迁移);
* 解析容错:键缺失/非法结构一律 null,绝不让脏布局打挂门户。
*/
private Map<String, Object> homeLayout;
/**
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
* 规避模板内拆分的表达式平台差异)。
*/
private List<String> footerAddressLines = new ArrayList<>();
public static PortalSiteSnapshot of(CmsSite site) {
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
snapshot.setId(site.getId());
@@ -77,10 +96,37 @@ public class PortalSiteSnapshot {
snapshot.setConfig(config);
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
snapshot.setSvcHallBase(ExtJsonUtils.getString(config, "svc_hall_base"));
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
return snapshot;
}
/** home_layout 容错归一:对象且 blocks 为数组才生效,否则视为无布局(回落态) */
private static Map<String, Object> normalizeHomeLayout(Object raw) {
if (!(raw instanceof Map)) {
return null;
}
Object blocks = ((Map<?, ?>) raw).get("blocks");
return blocks instanceof List ? (Map<String, Object>) raw : null;
}
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
private static List<String> splitAddressLines(String raw) {
List<String> lines = new ArrayList<>();
if (raw == null || raw.trim().isEmpty()) {
return lines;
}
for (String line : raw.split("\r?\n")) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
lines.add(trimmed);
}
}
return lines;
}
/**
* 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss
* 的操作由预览方在拷贝上进行,不影响缓存中的原快照。
@@ -97,6 +143,7 @@ public class PortalSiteSnapshot {
copy.setConfig(config);
copy.setDomain(domain);
copy.setPublicBaseUrl(publicBaseUrl);
copy.setSvcHallBase(svcHallBase);
copy.setCategories(categories);
copy.setBanners(banners);
copy.setLinkGroups(linkGroups);
@@ -104,6 +151,8 @@ public class PortalSiteSnapshot {
copy.setThemeParams(themeParams);
copy.setThemeVarsCss(themeVarsCss);
copy.setCustomCss(customCss);
copy.setHomeLayout(homeLayout);
copy.setFooterAddressLines(footerAddressLines);
return copy;
}
}
@@ -34,6 +34,13 @@ public class BannerVo {
private Integer status;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
private String description;
private String ctaText;
private String ctaUrl;
private java.time.LocalDateTime eventDate;
private String location;
public static BannerVo of(CmsBanner b) {
BannerVo vo = new BannerVo();
vo.setId(b.getId());
@@ -47,6 +54,11 @@ public class BannerVo {
vo.setStartTime(b.getStartTime());
vo.setEndTime(b.getEndTime());
vo.setStatus(b.getStatus());
vo.setDescription(b.getDescription());
vo.setCtaText(b.getCtaText());
vo.setCtaUrl(b.getCtaUrl());
vo.setEventDate(b.getEventDate());
vo.setLocation(b.getLocation());
return vo;
}
}
@@ -27,6 +27,9 @@ public class CategoryTreeNode {
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
private String homeStyle;
/** 单页栏目内容(type=2 时编辑/渲染用) */
private String content;
@@ -50,6 +53,7 @@ public class CategoryTreeNode {
node.setType(c.getType());
node.setLinkUrl(c.getLinkUrl());
node.setTemplate(c.getTemplate());
node.setHomeStyle(c.getHomeStyle());
node.setContent(c.getContent());
node.setSortNo(c.getSortNo());
node.setSeoTitle(c.getSeoTitle());
@@ -0,0 +1,39 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsPerson;
import lombok.Data;
import java.time.LocalDateTime;
/** 名师风采人物(docs/20 D-7) */
@Data
public class PersonVo {
private Integer id;
private Integer siteId;
private String name;
private String title;
private String bio;
private String avatar;
private Integer categoryId;
private Integer sortNo;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public static PersonVo of(CmsPerson p) {
PersonVo vo = new PersonVo();
vo.setId(p.getId());
vo.setSiteId(p.getSiteId());
vo.setName(p.getName());
vo.setTitle(p.getTitle());
vo.setBio(p.getBio());
vo.setAvatar(p.getAvatar());
vo.setCategoryId(p.getCategoryId());
vo.setSortNo(p.getSortNo());
vo.setStatus(p.getStatus());
vo.setCreateTime(p.getCreateTime());
vo.setUpdateTime(p.getUpdateTime());
return vo;
}
}
@@ -5,6 +5,7 @@ import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 主题包 VO(管理端主题列表,方案 §5.2)。
@@ -30,6 +31,12 @@ public class ThemeVo {
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
private List<ThemeParamVo> params;
/**
* 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key;
* 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。
*/
private Map<String, String> paramValues;
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
private String source;
@@ -82,6 +89,8 @@ public class ThemeVo {
for (ThemeParam p : info.getParams()) {
vo.getParams().add(ThemeParamVo.of(p));
}
vo.setParamValues(info.getParamValues() == null
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
return vo;
}
}
@@ -0,0 +1,16 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsPerson;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/** 名师风采(docs/20 D-7) */
public interface CmsPersonRepository extends JpaRepository<CmsPerson, Integer> {
/** 管理端:站点全量(含停用,按排序) */
List<CmsPerson> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId);
/** 门户:启用人物(可选栏目过滤),按排序 */
List<CmsPerson> findBySiteIdAndStatusOrderBySortNoAscIdAsc(Integer siteId, Integer status);
}
@@ -8,8 +8,24 @@ import java.util.Optional;
public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
/**
* 按站点编码取站点(严格版:命中多条时抛 IncorrectResultSizeDataAccessException)。
*
* <p>语义说明:{@code code} 自 W4 起不是全局唯一(唯一键为 {@code uk_site_parent_code(parent_site_id, code)}),
* 多校部署下不同父站可挂同名 code 子站。**生产代码请用**
* {@link #findFirstByCodeOrderByIdAsc(String)}(确定性取最早一条,不会因歧义 500);
* 本方法语义为"断言 code 唯一",保留给集成测试做唯一性/落库断言。
*/
Optional<CmsSite> findByCode(String code);
/**
* 按站点编码取站点(取 id 最小者)——生产路径口径。
*
* <p>歧义时不再抛异常导致 500,而是确定性取最早创建的站点;跨服务单键定位的正解仍是
* (siteCode,itemCode) 成对 + 建站保证 code 全局唯一(设计 §1.3/R4)。
*/
Optional<CmsSite> findFirstByCodeOrderByIdAsc(String code);
/** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */
Optional<CmsSite> findByPath(String path);
@@ -27,11 +27,17 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
@@ -46,6 +52,10 @@ public class AttachmentService {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
/** 富文本资产地址(img src / a href,单双引号皆可) */
private static final Pattern ASSET_URL_PATTERN =
Pattern.compile("(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE);
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
private static final List<String> ALLOWED_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
@@ -56,8 +66,13 @@ public class AttachmentService {
private static final List<String> IMAGE_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 */
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site");
/**
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
* category=单页栏目贴图 / person=名师头像(docs/20 D-7)。svc_item/svc_item_asset 两个事项场景
* 已随事项库迁出办事大厅(V2.0,见 docs/19 §九),CMS 侧下线。
*/
private static final List<String> ALLOWED_SCENES =
Arrays.asList("banner", "link", "site", "category", "person");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@@ -69,12 +84,13 @@ public class AttachmentService {
@Value("${FILE_WEB_PATH:/cms/upload/}")
private String fileWebPath;
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site,B2 初步标记) */
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category) */
@Transactional
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
CmsSite site = siteService.requireManageable(user, siteId);
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site)");
throw new BizException(ErrorCode.PARAM_ERROR,
"scene 非法:" + scene + "(仅 banner/link/site/category)");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
@@ -155,6 +171,64 @@ public class AttachmentService {
});
}
/**
* 把任意上传地址归一为库内 webUrl 形态(相对 FILE_WEB_PATH 起始段)。
*
* <p>兼容相对地址({@code /cms/upload/..})与带站点/网关前缀的绝对地址;非本站上传地址返回空
* (外链、data: 等一律不参与场景引用)。
*/
public Optional<String> localWebUrl(String url) {
if (!StringUtils.hasText(url) || !StringUtils.hasText(fileWebPath)) {
return Optional.empty();
}
int start = url.indexOf(fileWebPath);
return start < 0 ? Optional.empty() : Optional.of(url.substring(start));
}
/**
* 抽出 HTML 中指向本站上传目录的资产地址(img src / a href),已归一为 webUrl。
*
* <p>用途:正文内嵌图这类"没有 id 列表可依赖"的引用,保存时以内容为权威口径重建归属。
*/
public Set<String> localAssetUrls(String html) {
Set<String> webUrls = new LinkedHashSet<>();
if (!StringUtils.hasText(html)) {
return webUrls;
}
Matcher matcher = ASSET_URL_PATTERN.matcher(html);
while (matcher.find()) {
localWebUrl(matcher.group(1)).ifPresent(webUrls::add);
}
return webUrls;
}
/**
* 内容派生引用的全量同步(事项库 R2):把 (refType, refId) 下的引用对齐到给定 webUrl 集合——
* 集合内标记归属、集合外解除归属(转回未使用),幂等。
*
* <p>为什么需要"解除"这一步:封面/正文图在编辑时可能被删掉或换掉,若不按内容同步,
* 旧资产的引用会永久挂在本事项上(既不被内容引用、也不进入未使用清理口径)。
*/
@Transactional
public int syncAssets(Integer siteId, String refType, Integer refId, Set<String> webUrls) {
Set<String> desired = webUrls == null ? Collections.emptySet() : webUrls;
List<CmsAttachment> stale = new ArrayList<>();
for (CmsAttachment a : attachmentRepository.findByRefTypeAndRefId(refType, refId)) {
if (!desired.contains(a.getWebUrl())) {
a.setRefType(null);
a.setRefId(null);
stale.add(a);
}
}
if (!stale.isEmpty()) {
attachmentRepository.saveAll(stale);
}
for (String webUrl : desired) {
markUsed(siteId, webUrl, refType, refId);
}
return desired.size();
}
/** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */
@Transactional
public void release(String refType, Integer refId) {
@@ -81,6 +81,12 @@ public class BannerService {
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
}
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
// docs/20 D-3/D-6:topic/event 位扩展字段透传(其余位一并落库,渲染端按位消费)
banner.setDescription(req.getDescription());
banner.setCtaText(req.getCtaText());
banner.setCtaUrl(req.getCtaUrl());
banner.setEventDate(req.getEventDate());
banner.setLocation(req.getLocation());
banner = bannerRepository.save(banner);
// B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等
attachmentService.release("banner", banner.getId());
@@ -33,6 +33,13 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class CategoryService {
/** 首页展示形态白名单(docs/11;空=主题默认卡) */
private static final java.util.Set<String> HOME_STYLE_VALUES =
new java.util.HashSet<>(java.util.Arrays.asList("news_grid", "media_focus"));
/** 祖先链上溯上限(防脏数据自环导致死循环;正常栏目树远低于此深度) */
private static final int MAX_ANCESTOR_WALK = 64;
private final CmsCategoryRepository categoryRepository;
private final CmsArticleRepository articleRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
@@ -70,7 +77,10 @@ public class CategoryService {
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
}
CmsCategory category;
if (StringUtils.hasText(req.getHomeStyle()) && !HOME_STYLE_VALUES.contains(req.getHomeStyle())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"首页展示形态非法:" + req.getHomeStyle() + "(仅支持 news_grid / media_focus)");
} CmsCategory category;
if (req.getId() == null) {
category = new CmsCategory();
category.setSiteId(site.getId());
@@ -87,6 +97,9 @@ public class CategoryService {
category.setType(type.getCode());
category.setLinkUrl(req.getLinkUrl());
category.setTemplate(req.getTemplate());
// F3(docs/20 §四):「首页展示形态」唯一入口收敛到首页布局编辑器,栏目管理不再编辑该字段——
// 入参缺省时保留已有值(存量数据不清除,无布局站点的回落渲染仍消费);显式传值仍校验(API 兼容)
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : category.getHomeStyle());
category.setContent(HtmlSanitizer.clean(req.getContent()));
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
category.setSeoTitle(req.getSeoTitle());
@@ -110,9 +123,29 @@ public class CategoryService {
if (!parent.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点");
}
if (category.getId() != null && parent.getParentId() != null
&& parent.getParentId().equals(category.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持把子栏目设为父栏目(成环)");
requireNoCycle(category, parent);
}
/**
* 防环:从目标父栏目沿<b>祖先链</b>上溯,遇到"正在保存的栏目自身"即为环。
*
* <p>原实现只挡一级父子互换(parent.parentId == 自己),三级及以上的环写得进去;
* 环会让整枝栏目在门户导航与栏目树上消失,故补成任意层级(前端下拉同步放开置灰,
* 改为保存前同口径校验 + 明确提示,前后端口径一致)。
*/
private void requireNoCycle(CmsCategory category, CmsCategory parent) {
if (category.getId() == null) {
return;
}
int guard = 0;
CmsCategory cursor = parent;
while (cursor != null && guard++ <= MAX_ANCESTOR_WALK) {
if (category.getId().equals(cursor.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"不支持把栏目挂到自身或自己的子栏目下(会成环)");
}
Integer next = cursor.getParentId();
cursor = (next == null || next == 0) ? null : categoryRepository.findById(next).orElse(null);
}
}
@@ -1,7 +1,7 @@
package com.yunhai.cms.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaModule;
import lombok.extern.slf4j.Slf4j;
@@ -12,11 +12,11 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 用户中心代理(Notice UserCenterService 同款逻辑平移):
* 模块菜单树来自基础平台 SDK(YschBaseOpenApi.getUserModule),扁平转树对齐前端 layout 契约。
* 模块菜单树来自基础平台 SDK(YschBaseOpenApi.getUserModule),层级由用户中心 {@code BoaModule.children}
* 直接下发,本侧仅映射为前端 layout 契约(modId/page/children)。
*/
@Slf4j
@Service
@@ -40,18 +40,18 @@ public class CmsUserCenterService {
if (modules == null || modules.isEmpty()) {
return Collections.emptyList();
}
return buildModuleTree(modules);
return toFrontendTree(modules);
}
/**
* 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望。
* 顶层取 parentId=0 或父节点不在列表中的节点,保持 SDK 返回顺序。
* BoaModule 树 → 前端 layout 期望的字段形状(递归,<b>只改名不组树</b>;Notice/svc-hall 同款口径)。<br>
* 层级与兄弟顺序均由用户中心决定(customId 升序),本侧不重排;叶子 children 为空列表。
*/
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
Map<Integer, BoaModule> moduleMap = modules.stream()
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
private List<Map<String, Object>> toFrontendTree(List<BoaModule> modules) {
List<Map<String, Object>> result = new ArrayList<>();
if (modules == null || modules.isEmpty()) {
return result;
}
for (BoaModule m : modules) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", m.getId());
@@ -63,22 +63,9 @@ public class CmsUserCenterService {
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
node.put("usable", true);
node.put("children", new ArrayList<>());
nodeMap.put(m.getId(), node);
node.put("children", toFrontendTree(m.getChildren()));
result.add(node);
}
List<Map<String, Object>> roots = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> node = nodeMap.get(m.getId());
int parentId = m.getParentId() != null ? m.getParentId() : 0;
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
roots.add(node);
} else {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
children.add(node);
}
}
return roots;
return result;
}
}
@@ -0,0 +1,94 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsPerson;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.PersonSaveRequest;
import com.yunhai.cms.model.vo.PersonVo;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsPersonRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
/**
* 名师风采人物管理(docs/20 D-7):站点级 CRUD + people 区块数据源。
* 头像附件引用走 scene=person(B2 模型);categoryId 须属于本站点(防跨站注入)。
*/
@Service
@RequiredArgsConstructor
public class PersonService {
private final CmsPersonRepository personRepository;
private final CmsCategoryRepository categoryRepository;
private final SiteService siteService;
private final AttachmentService attachmentService;
private final ApplicationEventPublisher eventPublisher;
public List<PersonVo> list(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
return personRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId).stream()
.map(PersonVo::of)
.collect(Collectors.toList());
}
@Transactional
public PersonVo save(BoaUser user, PersonSaveRequest req) {
siteService.requireManageable(user, req.getSiteId());
if (req.getCategoryId() != null) {
CmsCategory category = categoryRepository.findById(req.getCategoryId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "所属栏目不存在"));
if (!category.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "所属栏目不属于本站点");
}
}
CmsPerson person;
if (req.getId() == null) {
person = new CmsPerson();
person.setSiteId(req.getSiteId());
} else {
person = personRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "人物不存在"));
if (!person.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该人物");
}
}
person.setName(req.getName());
person.setTitle(req.getTitle());
person.setBio(req.getBio());
person.setAvatar(req.getAvatar());
person.setCategoryId(req.getCategoryId());
person.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
person.setStatus(req.getStatus() == null ? 1 : req.getStatus());
person = personRepository.save(person);
// B2 附件引用:先解引用再回写新头像(编辑换图时旧图回未使用);幂等
attachmentService.release("person", person.getId());
if (StringUtils.hasText(req.getAvatar())) {
attachmentService.markUsed(req.getSiteId(), req.getAvatar(), "person", person.getId());
}
eventPublisher.publishEvent(new SiteContentChangedEvent(req.getSiteId(), "person"));
return PersonVo.of(person);
}
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsPerson person = personRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "人物不存在"));
siteService.requireManageable(user, req.getSiteId());
if (!person.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该人物");
}
personRepository.delete(person);
attachmentService.release("person", person.getId());
eventPublisher.publishEvent(new SiteContentChangedEvent(req.getSiteId(), "person"));
}
}
@@ -54,7 +54,7 @@ public class PortalQueryService {
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
public PortalSiteSnapshot siteSnapshot(String siteCode) {
CmsSite site = siteRepository.findByCode(siteCode).orElse(null);
CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null);
return assembleSnapshot(site);
}
@@ -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());
@@ -194,6 +194,7 @@ public class PortalQueryService {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
data.getItems().add(item);
@@ -246,6 +247,7 @@ public class PortalQueryService {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -279,17 +281,20 @@ 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;
}
/** config_json.theme_params 原始值(非对象形态按无参数处理) */
/**
* 参数原始值(工作项 T:参数微调上移为皮肤包级):取当前主题的包级 {@code paramValues}
* ({@code themes/.meta/{name}.json} 覆盖层)。站点 {@code config_json.theme_params} 已退役、不再读取
* (存量残留由 SiteService 的 RESERVED 键容忍,不影响门户)。
*/
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params");
return raw instanceof Map ? (Map<?, ?>) raw : null;
return themeRegistry.paramValuesOf(snapshot.getTheme());
}
}
@@ -4,7 +4,7 @@ import com.yunhai.cms.model.vo.SchoolVo;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
@@ -39,6 +39,7 @@ public class SiteService {
private final CmsAuthProperties authProperties;
private final ApplicationEventPublisher eventPublisher;
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
private final com.yunhai.cms.repository.CmsCategoryRepository categoryRepository;
/**
* B2 附件引用:站点配置 logo/favicon 回写场景引用。
@@ -59,6 +60,40 @@ public class SiteService {
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** home_layout(docs/16 §4.6 + v2 手册 §2.8 + docs/20 D 组件库扩展)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
// docs/20 D-1~D-9 新组件
"stats_band", "featured_topics", "video_tour", "events", "people", "badges")));
private static final int HOME_LAYOUT_MAX_BLOCKS = 30;
private static final int HOME_LAYOUT_MAX_BLOCK_KEYS = 32;
private static final int HOME_LAYOUT_MAX_TEXT = 100;
private static final int HOME_LAYOUT_SIZE_MIN = 1;
private static final int HOME_LAYOUT_SIZE_MAX = 20;
/** quick_links(§2.8 类型 A):内联入口宫格上限与单项 name 长度 */
private static final int HOME_LAYOUT_MAX_QUICK_LINKS = 12;
private static final int HOME_LAYOUT_MAX_LINK_NAME = 20;
/** notice_bar(§2.8 类型 B):公告正文上限 */
private static final int HOME_LAYOUT_MAX_NOTICE_TEXT = 200;
/** 区块内手填链接:仅放行 http(s) 绝对地址或站内根相对路径(拒绝 javascript: 等伪协议) */
private static final Pattern HOME_LAYOUT_LINK_PATTERN = Pattern.compile("^(https?://\\S+|/.+)$");
/** notice_bar 提示级别白名单(缺省 info) */
private static final Set<String> HOME_LAYOUT_NOTICE_LEVELS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("info", "warn")));
// ===== docs/20 D 组件库新增约束 =====
/** stats_band.stats 项数上限 */
private static final int HOME_LAYOUT_MAX_STATS = 8;
/** featured_topics.items 项数上限 */
private static final int HOME_LAYOUT_MAX_TOPICS = 6;
/** badges.items 项数上限 */
private static final int HOME_LAYOUT_MAX_BADGES = 8;
/** 区块内图标标识符(D-2 内置图标集 key) */
private static final Pattern HOME_LAYOUT_ICON_PATTERN = Pattern.compile("^[a-z0-9-]{1,30}$");
/** gallery 布局形态(D-8 campus_wall) */
private static final Set<String> HOME_LAYOUT_GALLERY_LAYOUTS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("grid", "masonry")));
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
private static final List<String> CUSTOM_CSS_BLACKLIST =
@@ -112,7 +147,7 @@ public class SiteService {
}
public CmsSite getByCode(String code) {
return siteRepository.findByCode(code).orElse(null);
return siteRepository.findFirstByCodeOrderByIdAsc(code).orElse(null);
}
public String configValue(CmsSite site, String key) {
@@ -162,8 +197,10 @@ public class SiteService {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
}
site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson());
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
validateConfigJson(req.getConfigJson(), site.getId());
// 工作项 T:参数微调上移为皮肤包级,站点不再持有参数值——themeParams 入参直接忽略
// (config_json 原样入库;theme_params 仍是 RESERVED 键以容忍存量/老前端提交,不报错)
site.setConfigJson(req.getConfigJson());
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site);
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
@@ -229,29 +266,6 @@ public class SiteService {
}
}
/**
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
* SPA 不再手拼 configJson 中的该节点。
*/
private String mergeThemeParams(SiteSaveRequest req, String theme) {
if (req.getThemeParams() == null) {
return req.getConfigJson();
}
Map<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
Map<String, String> filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams());
if (filtered.isEmpty()) {
config.remove("theme_params");
} else {
config.put("theme_params", filtered);
}
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
}
}
/**
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
@@ -287,11 +301,12 @@ public class SiteService {
/**
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
* ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
* 系统托管键 theme_params 直通;home_layout 走结构校验(docs/16 §4.6)——
* 此前「非字符串值原样放行」的口子收紧到该键;其余非字符串值(wechat 等预留对象)不校验原样放行。
*/
private void validateConfigJson(String configJson) {
private void validateConfigJson(String configJson, Integer siteId) {
if (!StringUtils.hasText(configJson)) {
return;
}
@@ -314,6 +329,10 @@ public class SiteService {
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
}
JsonNode value = entry.getValue();
if ("home_layout".equals(key)) {
validateHomeLayout(value, siteId);
return;
}
if (!value.isTextual()) {
return;
}
@@ -337,6 +356,11 @@ public class SiteService {
}
break;
case "public_base_url":
case "svc_hall_base":
case "social_wechat":
case "social_weibo":
case "social_douyin":
case "social_bilibili":
if (!URL_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"对外基地址必须是完整的 http(s):// 地址:" + text);
@@ -344,6 +368,9 @@ public class SiteService {
break;
case "logo":
case "favicon":
case "qr_wechat":
case "qr_weibo":
case "news_bg_image":
if (!ASSET_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");
@@ -355,6 +382,282 @@ public class SiteService {
});
}
/**
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键;
* type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、字符串字段(title/subtitle)≤100;
* quick_links / notice_bar 另有分支校验(见 {@link #validateQuickLinksBlock} / {@link #validateNoticeBarBlock});
* categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
*/
private void validateHomeLayout(JsonNode node, Integer siteId) {
if (!node.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局必须是 JSON 对象({version, blocks})");
}
if (node.has("version") && !node.get("version").isInt()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 version 必须是整数");
}
JsonNode blocks = node.get("blocks");
if (blocks == null || !blocks.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 blocks 必须是数组");
}
if (blocks.size() > HOME_LAYOUT_MAX_BLOCKS) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块数量不能超过 " + HOME_LAYOUT_MAX_BLOCKS + " 个");
}
Set<Integer> referencedIds = new java.util.HashSet<>();
for (JsonNode block : blocks) {
if (!block.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块必须是对象");
}
if (block.size() > HOME_LAYOUT_MAX_BLOCK_KEYS) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局单个区块键数不能超过 " + HOME_LAYOUT_MAX_BLOCK_KEYS + " 个");
}
JsonNode type = block.get("type");
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText()));
}
if ("quick_links".equals(type.asText())) {
validateQuickLinksBlock(block);
} else if ("notice_bar".equals(type.asText())) {
validateNoticeBarBlock(block);
} else if ("stats_band".equals(type.asText())) {
validateStatsBandBlock(block);
} else if ("featured_topics".equals(type.asText())) {
validateFeaturedTopicsBlock(block);
} else if ("video_tour".equals(type.asText())) {
validateVideoTourBlock(block);
} else if ("badges".equals(type.asText())) {
validateBadgesBlock(block);
} else if ("gallery".equals(type.asText())) {
JsonNode layout = block.get("layout");
if (layout != null && (!layout.isTextual()
|| !HOME_LAYOUT_GALLERY_LAYOUTS.contains(layout.asText()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "gallery layout 只能是 grid 或 masonry");
}
}
JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
|| size.intValue() > HOME_LAYOUT_SIZE_MAX)) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块条数须在 " + HOME_LAYOUT_SIZE_MIN + "~" + HOME_LAYOUT_SIZE_MAX + " 之间");
}
for (String textKey : new String[]{"title", "subtitle"}) {
JsonNode text = block.get(textKey);
if (text != null && (!text.isTextual() || text.asText().length() > HOME_LAYOUT_MAX_TEXT)) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块 " + textKey + " 须为不超过 " + HOME_LAYOUT_MAX_TEXT + " 字的文本");
}
}
JsonNode categoryIds = block.get("categoryIds");
if (categoryIds != null) {
if (!categoryIds.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须是数组");
}
for (JsonNode id : categoryIds) {
if (!id.isNumber() || id.intValue() <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须为正整数");
}
referencedIds.add(id.intValue());
}
}
// people 区块(docs/20 D-7):categoryId 单值,同走跨站注入防护
JsonNode categoryId = block.get("categoryId");
if (categoryId != null) {
if (!categoryId.isNumber() || categoryId.intValue() <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryId 必须为正整数");
}
referencedIds.add(categoryId.intValue());
}
}
if (!referencedIds.isEmpty()) {
if (siteId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点尚未创建,首页布局不能引用栏目");
}
// 防跨站注入:引用的栏目必须属于本站点
List<com.yunhai.cms.entity.CmsCategory> cats = categoryRepository.findAllById(referencedIds);
for (com.yunhai.cms.entity.CmsCategory c : cats) {
if (!siteId.equals(c.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不属于本站点的栏目(id=" + c.getId() + ")");
}
}
if (cats.size() < referencedIds.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不存在的栏目");
}
}
}
/** quick_links(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):items 必填、1~12 项,每项 {name ≤20 字, url 合法, icon 可选标识符} */
private void validateQuickLinksBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links 区块必须包含 items(入口列表)");
}
if (!items.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 必须是数组");
}
if (items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 至少 1 项");
}
if (items.size() > HOME_LAYOUT_MAX_QUICK_LINKS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links items 不能超过 " + HOME_LAYOUT_MAX_QUICK_LINKS + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 每项必须是对象({name, url})");
}
JsonNode name = item.get("name");
if (name == null || !name.isTextual() || name.asText().trim().isEmpty()
|| name.asText().trim().length() > HOME_LAYOUT_MAX_LINK_NAME) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links 项 name 须为不超过 " + HOME_LAYOUT_MAX_LINK_NAME + " 字的文本");
}
requireLayoutLink(item.get("url"), "quick_links 项");
JsonNode icon = item.get("icon");
if (icon != null && (!icon.isTextual() || icon.asText().isEmpty()
|| !HOME_LAYOUT_ICON_PATTERN.matcher(icon.asText()).matches())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links 项 icon 须为内置图标标识符(小写字母/数字/短横线,如 zhaosheng)");
}
}
}
/** notice_bar(v2 手册 §2.8 类型 B):text 必填 ≤200 字;url 可选;level 可选且限 info/warn */
private void validateNoticeBarBlock(JsonNode block) {
JsonNode text = block.get("text");
if (text == null || !text.isTextual() || text.asText().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar 区块必须填写 text(公告正文)");
}
if (text.asText().length() > HOME_LAYOUT_MAX_NOTICE_TEXT) {
throw new BizException(ErrorCode.PARAM_ERROR,
"notice_bar text 不能超过 " + HOME_LAYOUT_MAX_NOTICE_TEXT + " 字");
}
if (block.has("url")) {
requireLayoutLink(block.get("url"), "notice_bar");
}
JsonNode level = block.get("level");
if (level != null && (!level.isTextual() || !HOME_LAYOUT_NOTICE_LEVELS.contains(level.asText()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar level 只能是 info 或 warn");
}
}
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url / featured_topics ctaUrl / video_tour url) */
private void requireLayoutLink(JsonNode url, String what) {
if (url == null || !url.isTextual() || !HOME_LAYOUT_LINK_PATTERN.matcher(url.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " url 须为 http(s):// 地址或站内路径(/ 开头)");
}
}
/** 区块内图片资产校验(站点上传路径 / 开头或 http(s) 地址) */
private void requireLayoutAsset(JsonNode asset, String what) {
if (asset == null || !asset.isTextual() || !ASSET_PATTERN.matcher(asset.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " 须为站点上传路径(/ 开头)或 http(s) 地址");
}
}
/** stats_band(docs/20 D-1):stats 数组 1~8 项,每项 {label ≤20, value ≤10, unit ≤10, icon 可选} */
private void validateStatsBandBlock(JsonNode block) {
JsonNode stats = block.get("stats");
if (stats == null || !stats.isArray() || stats.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 区块必须填写 stats(统计项数组)");
}
if (stats.size() > HOME_LAYOUT_MAX_STATS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"stats_band stats 不能超过 " + HOME_LAYOUT_MAX_STATS + " 项");
}
for (JsonNode item : stats) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band stats 每项必须是对象({label, value, unit})");
}
JsonNode label = item.get("label");
if (label == null || !label.isTextual() || label.asText().trim().isEmpty()
|| label.asText().trim().length() > 20) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 label 须为不超过 20 字的文本");
}
JsonNode value = item.get("value");
if (value == null || !value.isTextual() || value.asText().trim().isEmpty()
|| value.asText().trim().length() > 10) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 value 须为不超过 10 字的数字文本(如 3.2 万)");
}
JsonNode unit = item.get("unit");
if (unit != null && (!unit.isTextual() || unit.asText().length() > 10)) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 unit 须为不超过 10 字的文本");
}
JsonNode icon = item.get("icon");
if (icon != null && (!icon.isTextual() || icon.asText().isEmpty()
|| !HOME_LAYOUT_ICON_PATTERN.matcher(icon.asText()).matches())) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 icon 须为内置图标标识符");
}
}
}
/** featured_topics(docs/20 D-3):items 1~6 项,每项 {title ≤50, description ≤200, image 资产, ctaText ≤20, ctaUrl 链接} */
private void validateFeaturedTopicsBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null || !items.isArray() || items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 区块必须填写 items(专题卡数组)");
}
if (items.size() > HOME_LAYOUT_MAX_TOPICS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"featured_topics items 不能超过 " + HOME_LAYOUT_MAX_TOPICS + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics items 每项必须是对象");
}
JsonNode title = item.get("title");
if (title == null || !title.isTextual() || title.asText().trim().isEmpty()
|| title.asText().trim().length() > 50) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 title 须为不超过 50 字的文本");
}
JsonNode description = item.get("description");
if (description != null && (!description.isTextual() || description.asText().length() > 200)) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 description 不能超过 200 字");
}
requireLayoutAsset(item.get("image"), "featured_topics 项 image");
JsonNode ctaText = item.get("ctaText");
if (ctaText != null && (!ctaText.isTextual() || ctaText.asText().length() > 20)) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 ctaText 不能超过 20 字");
}
if (item.has("ctaUrl")) {
requireLayoutLink(item.get("ctaUrl"), "featured_topics 项");
}
}
}
/** video_tour(docs/20 D-5):cover 资产 + url 链接 + title ≤100 */
private void validateVideoTourBlock(JsonNode block) {
requireLayoutAsset(block.get("cover"), "video_tour cover");
requireLayoutLink(block.get("url"), "video_tour");
}
/** badges(docs/20 D-9):badgeItems 1~8 项,每项 {label ≤20, desc ≤50, image 资产}
* (键名与前端 cleanBlock 序列化一致——避免与 quick_links/featured_topics 的 items 语义混淆) */
private void validateBadgesBlock(JsonNode block) {
JsonNode items = block.get("badgeItems");
if (items == null || !items.isArray() || items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 区块必须填写 items(徽章数组)");
}
if (items.size() > HOME_LAYOUT_MAX_BADGES) {
throw new BizException(ErrorCode.PARAM_ERROR,
"badges items 不能超过 " + HOME_LAYOUT_MAX_BADGES + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges items 每项必须是对象");
}
JsonNode label = item.get("label");
if (label == null || !label.isTextual() || label.asText().trim().isEmpty()
|| label.asText().trim().length() > 20) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 项 label 须为不超过 20 字的文本");
}
JsonNode desc = item.get("desc");
if (desc != null && (!desc.isTextual() || desc.asText().length() > 50)) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 项 desc 不能超过 50 字");
}
requireLayoutAsset(item.get("image"), "badges 项 image");
}
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
@@ -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;
}
@@ -0,0 +1,49 @@
package com.yunhai.cms.svcitem.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 开放接口签名强制(事项库详细设计 §3.4,照 UserBase OpenApiAuthEnforceConfiguration 范式):
* yh-app-auth 的 {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器
* CAS 流;本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
* CMS 的 /oapi/** 无用户会话语义,一律应用签名(X-App-Key/X-Timestamp/X-Signature 三件套)。
*/
@Configuration
public class OpenApiAuthEnforceConfiguration {
@Bean
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new RequireAppSignFilter());
bean.addUrlPatterns("/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
return bean;
}
public static class RequireAppSignFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
return;
}
chain.doFilter(request, response);
}
}
}
@@ -0,0 +1,31 @@
package com.yunhai.cms.svcitem.model.query;
import lombok.Data;
/**
* 门户服务中心列表查询(事项库详细设计 §5.2:全新查询面——obj/cat/dept 三维任意组合 + 关键词)。
* 空即不筛;状态固定 status=1(服务端收口,不由调用方传入)。
*/
@Data
public class SvcItemPortalQuery {
private Integer siteId;
/** 服务对象 dict_key(可空) */
private String objectKey;
/** 事项类别 dict_key(可空) */
private String categoryKey;
/** 服务部门组节点 id(可空) */
private Integer deptGroupId;
/** 关键词(title/summary LIKE,可空) */
private String keyword;
/** 页码,1 起 */
private Integer page = 1;
/** 页长 */
private Integer pageSize = 10;
}
@@ -0,0 +1,81 @@
package com.yunhai.cms.svcitem.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 门户服务中心三组筛选项 + 计数(已发布口径,事项库详细设计 §5.2/§6.1)。
* 本对象为 cms:svcItem 缓存体(key=filters:{siteId}):仅承载选项与计数,
* active/url 由控制器按当前请求装配为 Facet(缓存体不可被请求态污染)。
*/
@Data
public class PortalServiceFilters {
/** 服务对象(object 字典,仅启用且有已发布事项引用的键) */
private List<Option> objects = new ArrayList<>();
/** 事项类别(category 字典,同上) */
private List<Option> categories = new ArrayList<>();
/** 服务部门(有已发布事项引用的组节点) */
private List<DeptOption> depts = new ArrayList<>();
@Data
public static class Option {
private final String key;
private final String name;
private final long count;
public Option(String key, String name, long count) {
this.key = key;
this.name = name;
this.count = count;
}
}
@Data
public static class DeptOption {
private final Integer id;
private final String name;
private final long count;
public DeptOption(Integer id, String name, long count) {
this.id = id;
this.name = name;
this.count = count;
}
}
/** 模板消费形态:{code|id, name, count, active, url}——url 由控制器预生成,th:href 直用 */
@Data
public static class Facet {
private final String code;
private final Integer id;
private final String name;
private final long count;
private final boolean active;
private final String url;
public static Facet ofKey(String code, String name, long count, boolean active, String url) {
return new Facet(code, null, name, count, active, url);
}
public static Facet ofId(Integer id, String name, long count, boolean active, String url) {
return new Facet(null, id, name, count, active, url);
}
}
}
@@ -0,0 +1,111 @@
package com.yunhai.cms.svcitem.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 门户事项指南页模型(cms:svcItem 缓存体,key=itemId;未发布/不存在缓存 null 哨兵)。
* applyUrl 为 ext_json 手填原始值;最终按钮 URL 三态优先级(手填 > svc_hall_base 生成 > 不渲染)
* 由 PortalController.doGuide 决定——本缓存体不掺入站点配置。
*/
@Data
public class PortalServiceItem {
private Integer id;
private Integer siteId;
private String itemCode;
private String title;
private String summary;
private String objectKey;
private String objectName;
private String categoryKey;
private String categoryName;
private Integer deptGroupId;
private String deptName;
private Integer handleMode;
private Integer onlineFlag;
private String handlePlace;
private String handleTimeLimit;
private String promiseTimeLimit;
private String contactPhone;
private String supervisePhone;
/** 已清洗入库内容;渲染期经 url.rewriteUploadUrls 补基准(withRewrittenHtml 拷贝,不污染缓存体) */
private String processHtml;
private String materialHtml;
private String noticeHtml;
private Integer faqCategoryId;
private Integer noticeCategoryId;
private Integer ruleCategoryId;
private String coverImage;
private LocalDateTime publishTime;
private Integer viewCount;
/** ext_json.apply_url 原始值(可空) */
private String applyUrl;
/** 绑定流程 GUID(可空;§3.7"自动生成"分支在线条件=onlineFlag==1 且 flowGuid 非空) */
private String flowGuid;
/** copy-on-write:渲染期补基准(同 doArticle 的 detail.withContent 先例,避免污染缓存对象) */
public PortalServiceItem withRewrittenHtml(String processHtml, String materialHtml, String noticeHtml) {
PortalServiceItem copy = new PortalServiceItem();
copy.setId(id);
copy.setSiteId(siteId);
copy.setItemCode(itemCode);
copy.setTitle(title);
copy.setSummary(summary);
copy.setObjectKey(objectKey);
copy.setObjectName(objectName);
copy.setCategoryKey(categoryKey);
copy.setCategoryName(categoryName);
copy.setDeptGroupId(deptGroupId);
copy.setDeptName(deptName);
copy.setHandleMode(handleMode);
copy.setOnlineFlag(onlineFlag);
copy.setHandlePlace(handlePlace);
copy.setHandleTimeLimit(handleTimeLimit);
copy.setPromiseTimeLimit(promiseTimeLimit);
copy.setContactPhone(contactPhone);
copy.setSupervisePhone(supervisePhone);
copy.setProcessHtml(processHtml);
copy.setMaterialHtml(materialHtml);
copy.setNoticeHtml(noticeHtml);
copy.setFaqCategoryId(faqCategoryId);
copy.setNoticeCategoryId(noticeCategoryId);
copy.setRuleCategoryId(ruleCategoryId);
copy.setCoverImage(coverImage);
copy.setPublishTime(publishTime);
copy.setViewCount(viewCount);
copy.setApplyUrl(applyUrl);
copy.setFlowGuid(flowGuid);
return copy;
}
}
@@ -0,0 +1,72 @@
package com.yunhai.cms.svcitem.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 门户服务中心列表数据(事项库详细设计 §5.2/§6.1)。
* 字段形态与既有 PortalListData 同构(items + page/totalPages/total),便于模板与契约复用;
* pageUrls 由控制器预生成(模板禁止手拼 query,带筛选时手拼必错)。
*/
@Data
public class PortalServiceListData {
private Integer siteId;
private Long total = 0L;
private Integer page = 1;
private Integer pageSize = 10;
private Integer totalPages = 0;
private java.util.List<Item> items = new java.util.ArrayList<>();
/** 分页链接(控制器装配:[{page, url, active}],url 携带当前筛选) */
private java.util.List<PageUrl> pageUrls = new java.util.ArrayList<>();
@Data
public static class Item {
private Integer id;
private String itemCode;
private String title;
private String summary;
private String coverImage;
private String deptName;
private String categoryName;
/** 1 线上 / 2 线下 / 3 线上线下 */
private Integer handleMode;
/** 1/0("在线办理"标识) */
private Integer onlineFlag;
private LocalDateTime publishTime;
}
@Data
public static class PageUrl {
private final int page;
private final String url;
private final boolean active;
public PageUrl(int page, String url, boolean active) {
this.page = page;
this.url = url;
this.active = active;
}
}
}
@@ -0,0 +1,29 @@
package com.yunhai.cms.svcitem.model.vo;
import com.yunhai.cms.entity.CmsAttachment;
import lombok.Data;
/**
* 指南页附件(ref_type=svc_item, ref_id=事项id;fileName/webUrl/fileSize 三字段契约,§6.2)。
* webUrl 为入库相对路径,渲染期由 url.upload(...) 补基准。
*/
@Data
public class SvcItemAttachmentVo {
private Integer id;
private String fileName;
private String webUrl;
private Long fileSize;
public static SvcItemAttachmentVo of(CmsAttachment attachment) {
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
vo.setId(attachment.getId());
vo.setFileName(attachment.getFileName());
vo.setWebUrl(attachment.getWebUrl());
vo.setFileSize(attachment.getFileSize());
return vo;
}
}
@@ -0,0 +1,37 @@
package com.yunhai.cms.svcitem.service;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import java.util.List;
/**
* 事项域进程内接缝(V2.0 拆分后收敛为**门户消费面**):
* 事项数据与管理能力已迁至独立"办事大厅"应用(svc-hall),CMS 只保留门户主题化展示壳,
* 本接口由 {@code SvcItemRemoteQuery}(经签名 oapi 调办事大厅 /oapi/svc/** 开放接口)实现,
* PortalController 等消费方零改动(原接口注释"拆分时换 @ConditionalOnProperty 实现"的兑现)。
* 原管理端方法与 snapshot 随管理功能/快照口一并迁出,见 docs/19 §九。
*/
public interface SvcItemQuery {
/** 门户:服务中心列表分页(三维筛选 + 关键词;实时查询不缓存) */
PortalServiceListData portalPage(SvcItemPortalQuery query);
/** 门户:三组筛选项 + 计数(已发布口径;缓存 filters:{siteId},remote 模式 TTL 过期) */
PortalServiceFilters portalFilters(Integer siteId);
/** 门户:指南页模型(不存在/未发布返回 null,缓存 null 哨兵 30s 防穿透) */
PortalServiceItem portalItem(Integer itemId, Integer siteId);
/** 门户:关联事项(同类别已发布 Top N,排除自身) */
List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit);
/** 门户:指南页附件(办事大厅 svc_item_guide 绑定,fileUrl 已是完整地址) */
List<SvcItemAttachmentVo> attachments(Integer itemId);
/** 门户:指南页浏览量异步累加(转发办事大厅 /oapi/svc/item/view,不承诺精确) */
void countView(Integer itemId);
}
@@ -0,0 +1,187 @@
package com.yunhai.cms.svcitem.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.http.IDHttp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 办事大厅开放接口客户端(V2.0 Part C.3):经 SDK {@link YschOpenCall} 命名目标
* {@code svchall}(服务目录 {@code yh.open.services.svchall})调 svc-hall {@code /oapi/svc/**};
* 出站签名/信封解包/错误映射全部由 SDK 承载——本应用零专属地址配置
* (《子应用配置优化方案》§4.2)。<br><br>
* <b>为何不能再用共享 {@code IDHttp}</b>:svc-hall 带 context-path {@code /svc-hall},而共享
* 拦截器只剥 {@code yh.open.base-path}(本应用 {@code /userbase})——原手工拼的 URL 会被签成
* {@code /svc-hall/oapi/...},服务端剥掉 ctx 后两侧 target 不一致,<b>必然 401</b>。
* {@link YschOpenCall} 的 per-target base-path 与提供方 context-path 对齐(同 svc-hall
* {@code CmsSiteDirectoryImpl} 的既有范式)。<br><br>
* <b>错误语义</b>:业务失败信封 {@code code!=0} → {@code DException}(携带 OpenApiErrorCode,
* 办事大厅"不存在/未发布"= code 2);传输/HTTP 失败 → {@code OpenApiException};401/403 →
* {@code DException("验签失败")}。降级判定见 {@code SvcItemRemoteQuery}。<br><br>
* 目录未显式配置时回落 legacy {@code yh.svchall.base-url}(迁移期兼容);两者皆无则构造期
* fail-fast——不给"沉默连不上 localhost"的假象。
*/
@Component
public class SvcHallClient {
private static final Logger log = LoggerFactory.getLogger(SvcHallClient.class);
/** 服务目录命名目标(svc-hall 未晋升 PlatformService 枚举,按名直调) */
static final String DIRECTORY_KEY = "svchall";
private static final String LEGACY_KEY = "yh.svchall.base-url";
private final YschOpenCall openCall;
private final IDHttp http;
private final String legacyBaseUrl;
private final boolean directoryResolved;
public SvcHallClient(YschOpenCall openCall,
IDHttp http,
@Value("${yh.svchall.base-url:}") String baseUrl) {
this.openCall = openCall;
this.http = http;
this.directoryResolved = openCall != null && openCall.hasTarget(DIRECTORY_KEY);
this.legacyBaseUrl = trimTrailing(baseUrl == null ? "" : baseUrl.trim());
if (!directoryResolved && legacyBaseUrl.isEmpty()) {
throw new IllegalStateException("办事大厅调用地址未配置:请配置服务目录 yh.open.services."
+ DIRECTORY_KEY + "(推荐,容器直连完整 URL)或回落键 " + LEGACY_KEY);
}
}
/** 是否走服务目录命名目标(配置自检用) */
public boolean directoryResolved() {
return directoryResolved;
}
/** GET /oapi/svc/items → data(分页 + 三维筛选 + 关键词) */
public JsonNode items(String siteCode, String objectKey, String categoryKey, Integer deptGroupId,
String keyword, Integer page, Integer pageSize) {
Map<String, String> query = new LinkedHashMap<>();
put(query, "siteCode", siteCode);
put(query, "objectKey", objectKey);
put(query, "categoryKey", categoryKey);
put(query, "keyword", keyword);
if (deptGroupId != null) {
query.put("deptGroupId", String.valueOf(deptGroupId));
}
if (page != null) {
query.put("page", String.valueOf(page));
}
if (pageSize != null) {
query.put("pageSize", String.valueOf(pageSize));
}
return get("/oapi/svc/items", query);
}
/** GET /oapi/svc/filters → data */
public JsonNode filters(String siteCode) {
Map<String, String> query = new LinkedHashMap<>();
put(query, "siteCode", siteCode);
return get("/oapi/svc/filters", query);
}
/** GET /oapi/svc/item/detail → data(itemId 或 itemCode 二选一) */
public JsonNode detail(String siteCode, Integer itemId, String itemCode) {
Map<String, String> query = new LinkedHashMap<>();
put(query, "siteCode", siteCode);
if (itemId != null) {
query.put("itemId", String.valueOf(itemId));
}
put(query, "itemCode", itemCode);
return get("/oapi/svc/item/detail", query);
}
/** POST /oapi/svc/item/view(siteCode+itemCode 口径;fire-and-forget,失败仅 warn) */
public void view(String siteCode, String itemCode) {
Map<String, String> query = new LinkedHashMap<>();
put(query, "siteCode", siteCode);
put(query, "itemCode", itemCode);
safeView(query);
}
/** POST /oapi/svc/item/view(itemId 直取口径;fire-and-forget,失败仅 warn) */
public void viewByItemId(Integer itemId) {
if (itemId == null) {
return;
}
Map<String, String> query = new LinkedHashMap<>();
query.put("itemId", String.valueOf(itemId));
safeView(query);
}
private void safeView(Map<String, String> query) {
try {
if (directoryResolved) {
openCall.post(DIRECTORY_KEY, "/oapi/svc/item/view", query, null, JsonNode.class);
} else {
http.post(legacyUrl("/oapi/svc/item/view", query), null, JsonNode.class);
}
} catch (Exception e) {
log.warn("[cms] 办事大厅浏览量累加失败 {}:{}", query, e.getMessage());
}
}
/** GET /oapi/svc/item/attachments(itemId 直取;fileUrl 已绝对化) */
public JsonNode attachments(Integer itemId) {
Map<String, String> query = new LinkedHashMap<>();
query.put("itemId", String.valueOf(itemId));
return get("/oapi/svc/item/attachments", query);
}
/** GET /oapi/svc/item/related(itemId 直取:同类别已发布 Top N) */
public JsonNode related(Integer itemId, Integer limit) {
Map<String, String> query = new LinkedHashMap<>();
query.put("itemId", String.valueOf(itemId));
if (limit != null) {
query.put("limit", String.valueOf(limit));
}
return get("/oapi/svc/item/related", query);
}
// ===== 出站(目录优先,legacy 回落)=====
private JsonNode get(String path, Map<String, String> query) {
if (directoryResolved) {
return openCall.get(DIRECTORY_KEY, path, query, JsonNode.class);
}
return http.get(legacyUrl(path, query), JsonNode.class);
}
/** legacy 形态:base-url + path + query(与原手工拼接的语义一致,保住既有调用方预期) */
private String legacyUrl(String path, Map<String, String> query) {
StringBuilder url = new StringBuilder(legacyBaseUrl).append(path);
boolean first = true;
for (Map.Entry<String, String> e : query.entrySet()) {
url.append(first ? '?' : '&').append(e.getKey()).append('=').append(encode(e.getValue()));
first = false;
}
return url.toString();
}
private static void put(Map<String, String> query, String key, String value) {
if (value != null && !value.isEmpty()) {
query.put(key, value);
}
}
private static String encode(String value) {
try {
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
} catch (Exception e) {
return value;
}
}
private static String trimTrailing(String url) {
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
}
@@ -0,0 +1,286 @@
package com.yunhai.cms.svcitem.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.http.DHttpException;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.IErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* SvcItemQuery 的远程实现(V2.0 拆分后的唯一实现):经 {@link SvcHallClient} 调办事大厅
* /oapi/svc/** 开放接口,把出参映射回原门户 VO——模板契约(docs/10 §3.4)零变化的保证。
* 缓存纪律:portalFilters/portalItem 维持原 @Cacheable 键(TTL 过期,无跨应用事件失效——
* 办事大厅变更后 CMS 最多滞后 5min);portalPage 实时调用。
*/
@Slf4j
@Service
public class SvcItemRemoteQuery implements SvcItemQuery {
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SvcHallClient client;
private final CmsSiteRepository siteRepository;
public SvcItemRemoteQuery(SvcHallClient client, CmsSiteRepository siteRepository) {
this.client = client;
this.siteRepository = siteRepository;
}
@Override
public PortalServiceListData portalPage(SvcItemPortalQuery query) {
String siteCode = siteCodeOf(query.getSiteId());
JsonNode data = client.items(siteCode, query.getObjectKey(), query.getCategoryKey(),
query.getDeptGroupId(), query.getKeyword(), query.getPage(), query.getPageSize());
PortalServiceListData out = new PortalServiceListData();
out.setSiteId(query.getSiteId());
out.setTotal(longVal(data, "total"));
out.setPage(intVal(data, "page"));
out.setPageSize(intVal(data, "pageSize"));
out.setTotalPages(intVal(data, "totalPages"));
JsonNode items = data.get("items");
if (items != null && items.isArray()) {
for (JsonNode row : items) {
PortalServiceListData.Item item = new PortalServiceListData.Item();
item.setId(intVal(row, "id"));
item.setItemCode(text(row, "itemCode"));
item.setTitle(text(row, "title"));
item.setSummary(text(row, "summary"));
item.setCoverImage(text(row, "coverImage"));
item.setDeptName(text(row, "deptName"));
item.setCategoryName(text(row, "categoryName"));
item.setHandleMode(intVal(row, "handleMode"));
item.setOnlineFlag(intVal(row, "onlineFlag"));
item.setPublishTime(time(row, "publishTime"));
out.getItems().add(item);
}
}
return out;
}
@Override
public PortalServiceFilters portalFilters(Integer siteId) {
String siteCode = siteCodeOf(siteId);
JsonNode data = client.filters(siteCode);
PortalServiceFilters out = new PortalServiceFilters();
for (JsonNode row : nodes(data, "objects")) {
out.getObjects().add(new PortalServiceFilters.Option(
text(row, "key"), text(row, "name"), longVal(row, "count")));
}
for (JsonNode row : nodes(data, "categories")) {
out.getCategories().add(new PortalServiceFilters.Option(
text(row, "key"), text(row, "name"), longVal(row, "count")));
}
for (JsonNode row : nodes(data, "depts")) {
out.getDepts().add(new PortalServiceFilters.DeptOption(
intVal(row, "id"), text(row, "name"), longVal(row, "count")));
}
return out;
}
@Override
public PortalServiceItem portalItem(Integer itemId, Integer siteId) {
String siteCode = siteCodeOf(siteId);
JsonNode row;
try {
row = client.detail(siteCode, itemId, null);
} catch (BizException e) {
// 兼容分支:服务内直抛 BizException 的旧口径(本链路实际走 DHttpException)
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
return null;
} catch (DHttpException e) {
// BP-R1-2:IDHttp 对传输失败与业务码非 0 一律抛 DHttpException(code=0 为传输类,
// 携带业务码为业务类)——原 catch(BizException) 是死代码。业务 code=2
// (不存在/未发布)→ 门户 404 语义(null 哨兵);传输/鉴权类失败上抛
// (P1-4 停机降级由 PortalController 统一承接,不混入 404)。
if (e.getCode() == 2) {
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
return null;
}
throw e;
} catch (RuntimeException e) {
// 目录路径(YschOpenCall)下的同义语义:业务失败信封 code!=0 → DException(携带
// OpenApiErrorCode,办事大厅"不存在/未发布"= 2);传输/HTTP 失败 → OpenApiException。
// 降级口径与 legacy 分支逐字一致,见上方注释。
if (businessAbsent(e)) {
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
return null;
}
throw e;
}
return toPortalItem(row);
}
@Override
public List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) {
List<PortalServiceItem> result = new ArrayList<>();
if (categoryKey == null || categoryKey.isEmpty()) {
return result;
}
JsonNode row;
try {
row = client.related(itemId, limit);
} catch (BizException e) {
return result;
} catch (DHttpException e) {
// 业务性缺失(code=2)→ 相关事项空;传输类失败上抛(停机降级口径见 portalItem 注释)
if (e.getCode() == 2) {
return result;
}
throw e;
} catch (RuntimeException e) {
if (businessAbsent(e)) {
return result;
}
throw e;
}
for (JsonNode rel : row.isArray() ? row : new ArrayList<JsonNode>()) {
PortalServiceItem vo = new PortalServiceItem();
vo.setId(intVal(rel, "id"));
vo.setItemCode(text(rel, "itemCode"));
vo.setTitle(text(rel, "title"));
vo.setDeptName(text(rel, "deptName"));
vo.setHandleMode(intVal(rel, "handleMode"));
vo.setCategoryKey(categoryKey);
result.add(vo);
}
return result;
}
@Override
public List<SvcItemAttachmentVo> attachments(Integer itemId) {
List<SvcItemAttachmentVo> result = new ArrayList<>();
JsonNode row;
try {
row = client.attachments(itemId);
} catch (BizException e) {
return result;
} catch (DHttpException e) {
// 业务性缺失(code=2)→ 附件空;传输类失败上抛(停机降级口径见 portalItem 注释)
if (e.getCode() == 2) {
return result;
}
throw e;
} catch (RuntimeException e) {
if (businessAbsent(e)) {
return result;
}
throw e;
}
for (JsonNode att : row.isArray() ? row : new ArrayList<JsonNode>()) {
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
vo.setId(intVal(att, "id"));
vo.setFileName(text(att, "fileName"));
vo.setWebUrl(text(att, "fileUrl"));
vo.setFileSize(longVal(att, "fileSize"));
result.add(vo);
}
return result;
}
@Override
public void countView(Integer itemId) {
// fire-and-forget:itemId 直取口径(办事大厅侧自行定位),客户端内部失败仅 warn
client.viewByItemId(itemId);
}
// ===== 装配 =====
/**
* "业务性缺失"判定(办事大厅 code=2 = 不存在/未发布)。两条出站路径的异常载体不同,
* 语义必须一致:legacy {@code IDHttp} → {@link DHttpException#getCode()} 为 int;
* 服务目录 {@code YschOpenCall} → {@link DException} 携带 {@code OpenApiErrorCode}。
*/
private static boolean businessAbsent(RuntimeException e) {
if (e instanceof DHttpException) {
return ((DHttpException) e).getCode() == 2;
}
if (e instanceof DException) {
IErrorCode code = ((DException) e).getCode();
return code != null && code.getCode() == 2;
}
return false;
}
private PortalServiceItem toPortalItem(JsonNode row) {
PortalServiceItem vo = new PortalServiceItem();
vo.setId(intVal(row, "id"));
vo.setItemCode(text(row, "itemCode"));
vo.setTitle(text(row, "title"));
vo.setSummary(text(row, "summary"));
vo.setObjectKey(text(row, "objectKey"));
vo.setObjectName(text(row, "objectName"));
vo.setCategoryKey(text(row, "categoryKey"));
vo.setCategoryName(text(row, "categoryName"));
vo.setDeptGroupId(intVal(row, "deptGroupId"));
vo.setDeptName(text(row, "deptName"));
vo.setHandleMode(intVal(row, "handleMode"));
vo.setOnlineFlag(intVal(row, "onlineFlag"));
vo.setHandlePlace(text(row, "handlePlace"));
vo.setHandleTimeLimit(text(row, "handleTimeLimit"));
vo.setPromiseTimeLimit(text(row, "promiseTimeLimit"));
vo.setContactPhone(text(row, "contactPhone"));
vo.setSupervisePhone(text(row, "supervisePhone"));
vo.setProcessHtml(text(row, "processHtml"));
vo.setMaterialHtml(text(row, "materialHtml"));
vo.setNoticeHtml(text(row, "noticeHtml"));
vo.setFaqCategoryId(intVal(row, "faqCategoryId"));
vo.setNoticeCategoryId(intVal(row, "noticeCategoryId"));
vo.setRuleCategoryId(intVal(row, "ruleCategoryId"));
vo.setCoverImage(text(row, "coverImage"));
vo.setPublishTime(time(row, "publishTime"));
vo.setViewCount(intVal(row, "viewCount"));
vo.setApplyUrl(text(row, "applyUrl"));
vo.setFlowGuid(text(row, "flowGuid"));
return vo;
}
private String siteCodeOf(Integer siteId) {
return siteRepository.findById(siteId)
.map(CmsSite::getCode)
.orElseThrow(() -> new IllegalArgumentException("站点不存在:" + siteId));
}
private static Iterable<JsonNode> nodes(JsonNode data, String field) {
JsonNode arr = data == null ? null : data.get(field);
return arr != null && arr.isArray() ? arr : new ArrayList<JsonNode>();
}
private static String text(JsonNode node, String field) {
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
}
private static Integer intVal(JsonNode node, String field) {
return node != null && node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null;
}
private static Long longVal(JsonNode node, String field) {
return node != null && node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null;
}
private static LocalDateTime time(JsonNode node, String field) {
String v = text(node, field);
if (v == null || v.isEmpty()) {
return null;
}
try {
return LocalDateTime.parse(v, TIME);
} catch (Exception e) {
return null;
}
}
}
@@ -36,6 +36,13 @@ public class ThemeInfo {
private List<ThemeParam> params = new ArrayList<>();
/**
* 包级参数值(工作项 T:参数微调从站点级上移为皮肤包级)。
* 值不写 jar 内 theme.json,一律落 {@code {cms.theme.dir}/.meta/{name}.json} 覆盖层,
* 由 {@link ThemeRegistry#applyMetaOverride} 在装载时叠加;缺项回落 params[].defaultValue。
*/
private Map<String, String> paramValues = new LinkedHashMap<>();
public String screenshotPath() {
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
}
@@ -24,6 +24,7 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
@@ -74,7 +75,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 +109,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 +125,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 +145,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 +218,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 +274,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 +293,21 @@ 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));
copyMetaParamValues(sourceName, newName);
themeRegistry.reload();
publishThemeChanged();
log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName);
@@ -286,10 +316,32 @@ public class ThemePackageService {
throw e;
} catch (Exception e) {
deleteRecursively(target);
throw new BizException(ErrorCode.PARAM_ERROR, "派生皮肤包失败:" + e.getMessage());
throw wrapFailure("派生", "派生皮肤包失败:", e);
}
}
/**
* 派生时复制源包的 paramValues(v1 §3.4):参数值存 <code>.meta/{name}.json</code> 覆盖层、
* 不在包目录内,复制包体天然带不过去 → 不显式带则新包回落 theme.json 默认值,
* "复制包做差异化" 就得重调一遍参数。仅复制 paramValues 节点(title/description 由派生表单写入新包 theme.json)。
*/
private void copyMetaParamValues(String sourceName, String newName) throws IOException {
Path source = Paths.get(themeDir, ".meta", sourceName + ".json");
if (!Files.isRegularFile(source)) {
return;
}
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode values = mapper.readTree(source.toFile()).path("paramValues");
if (!values.isObject() || values.size() == 0) {
return;
}
Path target = Paths.get(themeDir, ".meta", newName + ".json");
Files.createDirectories(target.getParent());
com.fasterxml.jackson.databind.node.ObjectNode root = mapper.createObjectNode();
root.set("paramValues", values);
Files.write(target, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
/** 内置包(可能在 jar 内)复制:PathMatchingResourcePatternResolver 遍历其全部资源 */
private void copyClasspathPackage(String sourceName, Path target) throws IOException {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
@@ -321,24 +373,59 @@ 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) {
return updateMeta(user, name, title, description, params, null);
}
/**
* 元数据 + 包级参数值编辑(工作项 T:参数微调从站点级迁到皮肤包级)。
*
* <p>title/description/params 语义与任务四一致(内置包 schema 拒改);{@code paramValues} 则
* <b>内置包与外部包统一写 .meta 覆盖层</b>——值不属于包体内容,包体(theme.json + 模板 + 资产)
* 保持只读语义不变,「同包不同配色」的差异化仍由复制皮肤包承担。
*
* @param paramValues null=不改;非 null=整体替换(空 Map=清空、恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃(同批改 schema 时以新 schema 为准)
*/
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> params, Map<String, String> paramValues) {
requireSuperAdmin(user);
if (!themeRegistry.contains(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
}
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 不可修改(仅显示名称/描述可改)");
}
Map<String, String> filteredValues = null;
if (paramValues != null) {
ThemeInfo schema = themeRegistry.get(name);
if (external && params != null) {
schema = new ThemeInfo();
schema.setParams(params);
}
filteredValues = schema.filterValidValues(paramValues);
}
Path json = Paths.get(themeDir, name, "theme.json");
try {
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);
}
if (description != null) {
root.put("description", description);
}
if (params != null) {
ThemeInfo probe = new ThemeInfo();
probe.setParams(params);
@@ -349,18 +436,70 @@ public class ThemePackageService {
}
root.set("params", mapper.valueToTree(params));
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter()
.writeValueAsBytes(root));
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
if (filteredValues != null) {
writeMetaOverride(name, null, null, filteredValues);
}
} else {
writeMetaOverride(name, title, description, filteredValues);
}
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);
}
}
/**
* .meta 覆盖层写入:title/description/paramValues 合并进 {themeDir}/.meta/{name}.json。
* 三者全空且未提交 paramValues(即任务四的"恢复默认"手势)→ 删除覆盖层;
* 显式提交空 paramValues 只摘掉 paramValues 节点,保留覆盖层里既有的 title/description。
*/
private void writeMetaOverride(String name, String title, String description,
Map<String, String> paramValues) throws IOException {
Path overlay = Paths.get(themeDir, ".meta", name + ".json");
boolean noTitle = !StringUtils.hasText(title);
boolean noDesc = description == null || description.trim().isEmpty();
if (noTitle && noDesc && paramValues == null) {
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);
}
if (paramValues != null) {
if (paramValues.isEmpty()) {
root.remove("paramValues");
} else {
root.set("paramValues", mapper.valueToTree(paramValues));
}
}
Files.createDirectories(overlay.getParent());
Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
// ===== 删除(T-W1-15/16)=====
public void remove(BoaUser user, String name) {
@@ -381,6 +520,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 +536,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 +603,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);
}
}
}
@@ -1,22 +1,40 @@
package com.yunhai.cms.theme;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用,
* 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移:
* specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、
* fragments/head.html、param 规范、screenshot 存在。
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1)。
* fragments/head.html、首页区块引用一致性(§2.5 坑⑤)、param 规范、screenshot 存在。
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1);
* readFn 读资源文本(UTF-8),读不到返回 null(不臆断,交由存在性校验与模板引擎兜底)。
*/
public final class ThemePackageValidator {
/** 本包首页区块模板(自研形态承载体) */
private static final String HOME_BLOCKS = "fragments/home-blocks.html";
/** Thymeleaf 模板引用对:~{<路径表达式> :: <fragment>( */
private static final Pattern TEMPLATE_REF =
Pattern.compile("~\\{([\\s\\S]*?)\\s*::\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\(");
/** fragment 定义:th:fragment="name(...)"(空格/逗号分隔的多签名一律取标识符) */
private static final Pattern FRAGMENT_DEF = Pattern.compile("th:fragment\\s*=\\s*\"([^\"]*)\"");
private ThemePackageValidator() {
}
/** 校验不通过抛 IllegalArgumentException(bad package 语义,Registry 剔除 + log.warn) */
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn) {
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn,
Function<String, String> readFn) {
if (info == null) {
throw new IllegalArgumentException("清单为空");
}
@@ -38,6 +56,7 @@ public final class ThemePackageValidator {
if (!existsFn.test("fragments/head.html")) {
throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
}
validateHomeBlockFragmentRefs(dirName, readFn);
validateParams(info);
String shot = info.screenshotPath();
if (!existsFn.test(shot)) {
@@ -72,4 +91,75 @@ public final class ThemePackageValidator {
}
}
}
/**
* 首页区块门禁(v2 手册 §2.5 坑⑤):home-blocks **不是**包的必需项——B 档包整段走
* themes/common 兜底骨架属正常形态,强制"8 形态齐全"会把它们剔除并静默回落 default(重大事故)。
* 只校验**引用一致性**:home.html 每把一种形态分发给本包 fragments/home-blocks
* (${themeBase} 拼接或 themes/&lt;目录名&gt;/ 字面两种写法),该文件就必须存在并定义被引用的
* fragment,否则门户首页渲染整页 500。缺项清单写进异常消息,由 Registry 剔除分支 log.warn 带出。
*/
static void validateHomeBlockFragmentRefs(String dirName, Function<String, String> readFn) {
String home = readFn.apply("home.html");
if (home == null || home.isEmpty()) {
return;
}
List<String> referenced = ownHomeBlockRefs(home, dirName);
if (referenced.isEmpty()) {
return;
}
String blocks = readFn.apply(HOME_BLOCKS);
if (blocks == null) {
throw new IllegalArgumentException("home.html 把区块分发给本包 " + HOME_BLOCKS
+ ",但该文件不存在(引用形态:" + join(referenced) + ")");
}
Set<String> defined = fragmentNamesOf(blocks);
List<String> missing = new ArrayList<>();
for (String name : referenced) {
if (!defined.contains(name)) {
missing.add(name);
}
}
if (!missing.isEmpty()) {
throw new IllegalArgumentException("本包 " + HOME_BLOCKS + " 缺 fragment 定义:" + join(missing)
+ "(home.html 已引用这些形态,门户首页渲染会整页 500)");
}
}
/** home.html 中指向**本包** home-blocks 的 fragment 名(按出现顺序去重;common 兜底引用不计入) */
static List<String> ownHomeBlockRefs(String homeHtml, String dirName) {
List<String> names = new ArrayList<>();
Matcher matcher = TEMPLATE_REF.matcher(homeHtml);
while (matcher.find()) {
String target = matcher.group(1);
if (!target.contains("/fragments/home-blocks") || target.contains("themes/common/")) {
continue;
}
boolean ownPackage = target.contains("themeBase") || target.contains("themes/" + dirName + "/");
String fragment = matcher.group(2);
if (ownPackage && !names.contains(fragment)) {
names.add(fragment);
}
}
return names;
}
/** 模板文本内声明的全部 fragment 名 */
static Set<String> fragmentNamesOf(String templateText) {
Set<String> names = new HashSet<>();
Matcher matcher = FRAGMENT_DEF.matcher(templateText);
while (matcher.find()) {
for (String signature : matcher.group(1).trim().split("[\\s,]+")) {
String name = signature.split("\\(")[0].trim();
if (!name.isEmpty()) {
names.add(name);
}
}
}
return names;
}
private static String join(List<String> names) {
return String.join("、", names);
}
}
@@ -9,9 +9,12 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -50,6 +53,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);
@@ -110,8 +116,10 @@ public class ThemeRegistry {
String dirName = dirNameOf(resource);
try {
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName, rel ->
new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists());
ThemePackageValidator.validate(info, dirName,
rel -> new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists(),
rel -> readClasspath("templates/themes/" + dirName + "/" + rel));
applyMetaOverride(info);
loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_BUILTIN);
} catch (Exception e) {
@@ -126,11 +134,14 @@ public class ThemeRegistry {
Path json = pkg.resolve("theme.json");
try {
ThemeInfo info = MAPPER.readValue(json.toFile(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName, rel -> Files.exists(pkg.resolve(rel)));
ThemePackageValidator.validate(info, dirName,
rel -> Files.exists(pkg.resolve(rel)),
rel -> readTextFile(pkg.resolve(rel)));
if (loaded.containsKey(info.getName())) {
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
continue;
}
applyMetaOverride(info);
loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_EXTERNAL);
} catch (Exception e) {
@@ -217,6 +228,24 @@ public class ThemeRegistry {
return REQUIRED_TEMPLATES;
}
/** 读 classpath 包内文本(UTF-8);读不到返回 null(区块门禁据此跳过,不臆断) */
private static String readClasspath(String location) {
try (InputStream in = new ClassPathResource(location).getInputStream()) {
return StreamUtils.copyToString(in, StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
/** 读外部包内文本(UTF-8);读不到返回 null */
private static String readTextFile(Path file) {
try {
return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
// ===== 资源定位 =====
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
@@ -229,9 +258,48 @@ public class ThemeRegistry {
}
}
/**
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
* 内置包 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());
}
if (meta.getParamValues() != null && !meta.getParamValues().isEmpty()) {
info.setParamValues(new LinkedHashMap<>(meta.getParamValues()));
}
} catch (Exception e) {
log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage());
}
}
/**
* 包级参数值(工作项 T):主题不存在返回空 Map(调用方免判空)。
* 返回不可变视图——参数值的唯一写入口是 {@code ThemePackageService.updateMeta}。
*/
public Map<String, String> paramValuesOf(String name) {
ThemeInfo info = name == null ? null : themes.get(name);
if (info == null || info.getParamValues() == null) {
return java.util.Collections.emptyMap();
}
return java.util.Collections.unmodifiableMap(info.getParamValues());
}
/** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */
protected List<Path> scanExternalPackages() {
List<Path> packages = new ArrayList<>();
protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
if (themeDir == null || themeDir.isEmpty()) {
return packages;
}
@@ -132,6 +132,41 @@ public class PortalUrlHelper {
return page != null && page > 1 ? url + "&page=" + page : url;
}
/**
* 服务中心列表基址(事项库 §5.4:模板禁止手拼 query,筛选/分页 URL 一律后端预生成)。
*/
public String serviceList() {
return baseUrl + "/service";
}
/** 服务中心列表带筛选/分页参数:null 与空串值跳过,值按 UTF-8 编码(LinkedHashMap 保序) */
public String serviceList(java.util.Map<String, Object> query) {
if (query == null || query.isEmpty()) {
return serviceList();
}
StringBuilder qs = new StringBuilder();
for (java.util.Map.Entry<String, Object> entry : query.entrySet()) {
String key = entry.getKey();
if (key == null || key.isEmpty()) {
continue;
}
String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
if (value.isEmpty()) {
continue;
}
if (qs.length() > 0) {
qs.append('&');
}
qs.append(urlEncode(key)).append('=').append(urlEncode(value));
}
return qs.length() == 0 ? serviceList() : serviceList() + "?" + qs;
}
/** 事项指南页 */
public String serviceItem(Object itemId) {
return baseUrl + "/sv/" + itemId;
}
/**
* 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。
* URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。
+32 -3
View File
@@ -60,12 +60,34 @@ yh:
# UserBase 注册后分配(进白名单),与 yh.open 保持一致;生产由 projman 注入
app-key: 100005
app-secret: 2c7885de594742cc840b8762910b2d2e
# 入站验签白名单:办事大厅 svc-hall(站点目录 /oapi/site/list 消费方,appKey=100018;
# P0-1 修复)。secret 与 svc-hall application.yml yh.app-auth.app-secret 逐字一致(联调态),
# 生产两侧由 projman/Nacos 注入同一密钥对
trusted-apps:
"100018": "svc-hall-dev-secret-100018"
open:
enabled: true
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com
base-path: /userbase
app-key: 100005
app-secret: 2c7885de594742cc840b8762910b2d2e
# 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 /
# YschOpenCall。svc-hall 未晋升 PlatformService 枚举,按名直调(完整 URL 拆 host+ctx,
# per-target base-path 与提供方 context-path 对齐 → 签名天然一致)。
# 容器直连形态(生产):http://svc-hall-service:10007/svc-hall(网关无 /oapi 路由)。
services:
svchall: http://localhost:8086
# 办事大厅(svc-hall)对接(V2.0 Part C.3):门户 service/guide 数据源。
# 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧须在 UserBase
# 注册表内可用);入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。
# 地址不再单独配置:见上方 yh.open.services.svchall(原 yh.svchall.base-url 已删)。
# env-config 管理端运行时配置(免登录 GET /api/env-config?target=admin,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_ADMIN
# 或同名键 yh.env-config.source.admin 注入(projman 配置管理维护),未配置时端点返回 {},
# 前端走同域推导兜底(serviceHost/baseHost 正确,ssoHost 为空登录跳转不可用)。
# 如需本地联调可打开:
# env-config:
# source:
# admin: '{"baseHost":"http://base.yunhaixingtong.com","ssoHost":"http://sso.yunhaixingtong.com","serviceHost":"/yschcms","isMock":false}'
# ===== CMS 专属配置 =====
cms:
@@ -78,6 +100,13 @@ cms:
# 含 context-path,如 https://base.yunhaixingtong.com/cms;生产由 projman 注入。
# 为空(本地开发默认)时按当前请求派生(反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
base-origin: ""
svc:
item:
# 【V2.0 拆分遗留说明】事项库已迁独立"办事大厅"应用(svc-hall),CMS 侧 SvcItemQuery
# 由 SvcItemRemoteQuery 经 oapi 调办事大厅;本配置已废弃(部门口径归办事大厅
# svchall.item.dept-group-type)。保留注释防误解。
dept-group-type: 7
logging:
level:
+63
View File
@@ -138,6 +138,69 @@ CREATE TABLE IF NOT EXISTS `cms_link` (
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接';
-- 7. 事项表(L1 事项库主表,事项库与办事大厅详细设计 §2.1;跨服务键 = siteCode + item_code)
CREATE TABLE IF NOT EXISTS `cms_service_item` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`site_id` INT NOT NULL COMMENT '所属站点',
`item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)',
`title` VARCHAR(200) NOT NULL COMMENT '事项名称',
`summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)',
`object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)',
`category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)',
`dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)',
`dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)',
`handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下',
`online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)',
`handle_place` VARCHAR(200) NULL COMMENT '办理地点',
`handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")',
`promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)',
`contact_phone` VARCHAR(64) NULL COMMENT '咨询电话',
`supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话',
`process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)',
`material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)',
`notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)',
`faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)',
`notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)',
`rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)',
`cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架',
`publish_time` DATETIME NULL COMMENT '发布时间',
`offline_time` DATETIME NULL COMMENT '下架时间',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)',
`ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)',
`create_by` VARCHAR(64) NULL,
`create_time` DATETIME NULL,
`update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`),
KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`),
KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`),
KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`),
KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`),
KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)';
-- 8. 事项字典表(服务对象/事项类别;不复用 cms_category——栏目树是内容目录,语义不同)
CREATE TABLE IF NOT EXISTS `cms_service_dict` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`site_id` INT NOT NULL COMMENT '所属站点',
`dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别',
`dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)',
`dict_name` VARCHAR(128) NOT NULL COMMENT '展示名',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)',
`ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)',
`create_by` VARCHAR(64) NULL,
`create_time` DATETIME NULL,
`update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`),
KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)';
-- ============================================================
-- 示例数据(联调首屏用,按需执行)
-- ============================================================
@@ -0,0 +1,31 @@
-- ============================================================
-- docs/20 D-3/D-6/D-7:首页新组件库数据结构变更(DDL 供 DBA 审阅,不自动执行)
-- 日期:2026-09-28
-- 说明:应用侧由 JPA ddl-auto=update 自动建列/建表;本脚本为生产发布前
-- 人工变更单底稿。执行前请备份相关表。
-- ============================================================
-- ① cms_banner 扩展列(D-3 专题大卡位 topic / D-6 学术活动位 event;均可空,老数据不受影响)
ALTER TABLE cms_banner
ADD COLUMN description VARCHAR(500) NULL COMMENT '专题大卡描述(position=topic)' AFTER status,
ADD COLUMN cta_text VARCHAR(100) NULL COMMENT '专题大卡 CTA 按钮文字(position=topic)' AFTER description,
ADD COLUMN cta_url VARCHAR(500) NULL COMMENT '专题大卡 CTA 跳转地址(position=topic)' AFTER cta_text,
ADD COLUMN event_date DATETIME NULL COMMENT '活动日期(position=event)' AFTER cta_url,
ADD COLUMN location VARCHAR(200) NULL COMMENT '活动地点(position=event)' AFTER event_date;
-- ② cms_person 新表(D-7 名师风采,people 区块数据源)
CREATE TABLE cms_person (
id INT AUTO_INCREMENT PRIMARY KEY,
site_id INT NOT NULL COMMENT '所属站点(cms_site.id)',
name VARCHAR(100) NOT NULL COMMENT '姓名',
title VARCHAR(100) NULL COMMENT '头衔/职称',
bio VARCHAR(500) NULL COMMENT '一句话简介',
avatar VARCHAR(500) NULL COMMENT '头像 URL(/cms/upload/** 或 http(s),空=首字徽章兜底)',
category_id INT NULL COMMENT '所属栏目外键(cms_category.id,空=未分组)',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同级排序(升序)',
status INT NOT NULL DEFAULT 1 COMMENT '0=停用 1=启用',
create_time DATETIME NULL,
update_time DATETIME NULL,
KEY idx_person_site (site_id, status, sort_no),
KEY idx_person_category (category_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '名师风采人物(docs/20 D-7)';
+2 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -20,7 +20,7 @@
<div class="code">400</div>
<h1>请求参数有误</h1>
<p>页面地址中的参数不正确(如页码格式错误)。<br>请修正后重试,或返回门户首页继续浏览。</p>
<a href="/cms/portal/">返回门户首页</a>
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
</div>
</body>
</html>
+2 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -20,7 +20,7 @@
<div class="code">404</div>
<h1>页面不存在或已下线</h1>
<p>您访问的页面可能已被删除、更改地址或暂时不可用。<br>请检查网址是否正确,或返回门户首页继续浏览。</p>
<a href="/cms/portal/">返回门户首页</a>
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
</div>
</body>
</html>
+2 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -20,7 +20,7 @@
<div class="code">500</div>
<h1>服务暂时不可用</h1>
<p>系统开小差了,工程师正在抢修。<br>请稍后刷新重试,或返回门户首页继续浏览。</p>
<a href="/cms/portal/">返回门户首页</a>
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
</div>
</body>
</html>
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -20,7 +20,7 @@
<div class="code">:(</div>
<h1>出错了</h1>
<p>请求处理出现异常,请稍后重试。<br>如持续出现,请联系管理员。</p>
<a href="/cms/portal/">返回门户首页</a>
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
</div>
</body>
</html>
@@ -0,0 +1,33 @@
# academic-navy(学府藏蓝)
高校官网风皮肤包:以 `scholar-blue` 为基座派生,配色与首页区块形态按综合类大学官网(浙江理工大学一类)重做。
配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染——派生包不自带区块形态时,
正文会落到 `themes/common` 的通用骨架,换肤就只剩页头页脚(线上事故根因,见《审计验收清单-20260919》)。
## 风格定位
- 主色藏蓝 `#0b4a8f` + 加深 `#063161` + 点缀金 `#b8934a`(标题左竖条与居中线脚走金线,区别于基座的全蓝语言)。
- 圆角收紧到 4/8px、页脚 `#0a2b4d`,行高压到 40px 一档,整体比基座更「机关报」而非「校园活动页」。
- 全部 11 个颜色参数可在站点 theme_params 微调(`primary/primaryDark/primaryLight/accent/navBg/footerBg/text/textLight/bg/cardBg/border`)。
## 首页区块形态(布局模式与回落模式同构)
| homeStyle | 呈现 | 类名 |
| -- | -- | -- |
| `news_grid` | 双栏要闻:左 3 份图文头条(16:7 封面 + 摘要 + 元信息)、右 2 份紧凑行;区块背景可取站点 `news_bg_image` | `an-split` / `an-feat-*` |
| `compact` | 日期方块行:46px 见方的「日 + 年月」方块,hover 反白 | `an-date-chip` |
| `media_focus` | 媒体聚焦:来源徽标 + 双列密行 | `an-src` / `an-block-list--two` |
| `image_wall` | 四列图片墙:封面 + 底部渐变遮罩标题,hover 抬图 | `an-wall` |
| (空/未知) | 整宽区块卡:头条 + 行式列表 | `an-block--flow` |
| `ticker` | 藏蓝要闻速递横条(单行不换行、超长省略,中文不塌列) | `an-ticker*` |
| `gallery` | 线上展厅:居中双语文区头 + 四列图墙 | `an-gallery` |
| `links` | 友情链接分组(胶囊标签行) | `an-links` |
## 契约要点
- 结构类前缀 `an-`,CSS 变量前缀 `--an-`;`:root` 把 `--an-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
- 回归防线:`AcademicSkinContractTest`(自带区块 fragment + 中文不塌列 + 契约变量别名 + 主色必须学术蓝)、
`PortalHomeLayoutIT#academicNavy_layoutUsesOwnFragments`(布局模式不落通用骨架)、`ThemeContractIT`(7 条路由)。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<rect width="96" height="96" rx="20" fill="#0a5aa8"/>
<path d="M48 16l8.6 17.5 19.3 2.8-14 13.6 3.3 19.2L48 60.1 30.8 69.1l3.3-19.2-14-13.6 19.3-2.8L48 16z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

@@ -0,0 +1,190 @@
/* academic-navy 主题脚本:顶栏日期 + 轮播(圆点/箭头/触摸) + 导航吸顶反蓝 + 移动端抽屉与二级手风琴。
与 default 同源逻辑,无任何外部依赖。 */
(function () {
'use strict';
/* ---------- 顶栏日期(本地时间,YYYY年M月D日 星期X) ---------- */
var dateEl = document.getElementById('siteDate');
if (dateEl) {
var now = new Date();
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
+ now.getDate() + '日 星期' + week;
}
/* ---------- 导航吸顶反蓝(桌面端;≤768 不吸顶) ---------- */
var nav = document.getElementById('anNav');
var DESKTOP_MIN = 769;
var navBaseTop = 0;
var holder = null;
function measureNavTop() {
unlockNav();
navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset;
}
function lockNav() {
if (!nav.classList.contains('an-nav--fixed')) {
holder = document.createElement('div');
holder.className = 'an-nav-holder';
holder.style.height = nav.offsetHeight + 'px';
nav.parentNode.insertBefore(holder, nav);
nav.classList.add('an-nav--fixed');
} else if (holder) {
holder.style.height = nav.offsetHeight + 'px';
}
}
function unlockNav() {
nav.classList.remove('an-nav--fixed');
if (holder && holder.parentNode) {
holder.parentNode.removeChild(holder);
}
holder = null;
}
function onScrollNav() {
if (window.innerWidth < DESKTOP_MIN) {
unlockNav();
return;
}
if (window.pageYOffset > navBaseTop) {
lockNav();
} else {
unlockNav();
}
}
if (nav) {
measureNavTop();
window.addEventListener('scroll', onScrollNav, { passive: true });
var resizeTimer = null;
window.addEventListener('resize', function () {
if (resizeTimer) { window.clearTimeout(resizeTimer); }
resizeTimer = window.setTimeout(function () {
measureNavTop();
onScrollNav();
}, 150);
});
}
/* ---------- 移动端抽屉 + 二级手风琴 ---------- */
var navToggle = document.querySelector('.an-nav-toggle');
var navList = document.querySelector('.an-nav-list');
if (navToggle && navList) {
navToggle.addEventListener('click', function () {
var open = navList.classList.toggle('an-open');
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
/* 窄屏下:点击带二级的项先展开面板,再点才跳转;宽屏交还给 CSS hover */
if (navList) {
navList.addEventListener('click', function (e) {
if (window.innerWidth >= DESKTOP_MIN) { return; }
var link = e.target.closest ? e.target.closest('a') : null;
if (!link) { return; }
var li = link.parentElement;
if (!li || !li.classList.contains('has-sub')) { return; }
var sub = li.querySelector('.an-sub');
if (!sub) { return; }
if (!li.classList.contains('an-sub-open')) {
e.preventDefault();
li.classList.add('an-sub-open');
}
});
}
/* ---------- 首页轮播 ---------- */
var root = document.querySelector('.an-banner');
var slides = document.querySelectorAll('.an-banner-slide');
var dots = document.querySelectorAll('.an-banner-dots button');
var prevBtn = document.querySelector('.an-banner-arrow.prev');
var nextBtn = document.querySelector('.an-banner-arrow.next');
if (!slides.length || !root) {
return;
}
var current = 0;
var timer = null;
var INTERVAL = 5000;
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() {
stop();
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, INTERVAL);
}
}
function stop() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
show(index);
start();
});
});
if (prevBtn) {
prevBtn.addEventListener('click', function () {
show(current - 1);
start();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', function () {
show(current + 1);
start();
});
}
root.addEventListener('mouseenter', stop);
root.addEventListener('mouseleave', start);
root.addEventListener('focusin', stop);
root.addEventListener('focusout', start);
root.setAttribute('tabindex', '-1');
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') {
show(current - 1);
start();
} else if (e.key === 'ArrowRight') {
show(current + 1);
start();
}
});
/* 触摸滑动切页 */
var touchX = null;
root.addEventListener('touchstart', function (e) {
touchX = e.changedTouches[0].clientX;
stop();
}, { passive: true });
root.addEventListener('touchend', function (e) {
if (touchX === null) { return; }
var dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 40) {
show(dx < 0 ? current + 1 : current - 1);
}
touchX = null;
start();
}, { passive: true });
start();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -0,0 +1,76 @@
<!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="an-page an-container" id="main-content">
<div class="an-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="an-side-nav-list">
<li class="an-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="an-page-card">
<nav class="an-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="an-crumb-sep" aria-hidden="true">/</span>
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="an-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="an-crumb-current">正文</span>
</nav>
<article class="an-article">
<h1 class="an-article-title" th:text="${article.title}">文章标题</h1>
<div class="an-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="an-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="an-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="an-sib prev"
th:href="${url.article(prevArticle.id)}">
<span class="an-sib-label">上一篇</span>
<span class="an-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="an-sib-none prev" th:if="${prevArticle == null}">
<span class="an-sib-label">上一篇</span>
<span class="an-sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="an-sib next"
th:href="${url.article(nextArticle.id)}">
<span class="an-sib-label">下一篇</span>
<span class="an-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="an-sib-none next" th:if="${nextArticle == null}">
<span class="an-sib-label">下一篇</span>
<span class="an-sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- academic-navy 公共页脚:深蓝渐变底 + 同心圆环纹理(CSS 绘制) + 三栏(品牌/联系备案/友链分组) + 底条校名。
数据绑定与 default 契约一致(linkGroups/contact/copyright/beian/logo/schName),仅换皮。 -->
<footer class="an-footer" th:fragment="footer">
<i class="an-ring an-ring--1" aria-hidden="true"></i>
<i class="an-ring an-ring--2" aria-hidden="true"></i>
<i class="an-ring an-ring--3" aria-hidden="true"></i>
<div class="an-footer-inner">
<!-- 品牌栏 -->
<div class="an-footer-brand">
<span class="an-footer-badge" th:if="${site.config['logo'] != null}">
<img class="an-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="an-footer-badge an-footer-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" focusable="false">
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
fill="currentColor"></path>
</svg>
</span>
<span class="an-footer-site" th:text="${site.name}">站点名称</span>
</div>
<!-- 联系/版权/备案 -->
<div class="an-footer-info">
<p class="an-footer-line" th:if="${site.config['contact'] != null}">
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['contact']}">联系方式</span>
</p>
<p class="an-footer-line" th:if="${site.config['copyright'] != null}">
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['copyright']}">版权信息</span>
</p>
<p class="an-footer-line" th:if="${site.config['beian'] != null}">
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['beian']}">备案号</span>
</p>
</div>
<!-- 友情链接(分组渲染;无分组整栏不渲染) -->
<div class="an-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="an-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="an-group-name" th:text="${group.groupName}">分组</span>
<p class="an-links-row">
<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>
</p>
</div>
</div>
</div>
<div class="an-footer-bottom">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</footer>
</html>
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<!-- academic-navy 公共 head 片段:与 default 契约一致(pageTitle/seo/canonical/og/themeVarsCss/favicon 兜底) -->
<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}">
<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')}">
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
<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,86 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- academic-navy 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 -->
<header class="an-header" th:fragment="header">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<!-- 顶栏 -->
<div class="an-topbar">
<div class="an-topbar-inner">
<span class="an-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="an-topbar-side" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
<div class="an-colleges">
<span class="an-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
<i class="an-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="an-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>
</div>
</div>
<!-- 品牌行 -->
<div class="an-brandbar">
<div class="an-brandbar-inner">
<div class="an-brand">
<span class="an-brand-badge" th:if="${site.config['logo'] != null}">
<img class="an-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
</span>
<span class="an-brand-badge an-brand-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false">
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
fill="currentColor"></path>
</svg>
</span>
<a class="an-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="an-search" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="请输入关键字…" aria-label="站内搜索">
<button type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">
<path d="M10.5 3a7.5 7.5 0 015.9 12.1l4.3 4.3-1.4 1.4-4.3-4.3A7.5 7.5 0 1110.5 3zm0 2a5.5 5.5 0 100 11 5.5 5.5 0 000-11z"
fill="currentColor"></path>
</svg>
</button>
</form>
</div>
</div>
<!-- 主导航:白底深蓝字;下拉为主蓝半透明面板(.an-sub 展开动效见 main.css);吸顶反蓝由 main.js 处理 -->
<nav class="an-nav" id="anNav" aria-label="栏目导航">
<div class="an-nav-inner">
<button class="an-nav-toggle" type="button" aria-label="展开菜单" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<ul class="an-nav-list">
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<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)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="an-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:主蓝半透明面板,顶部亮线,项 hover 加深+左亮条 -->
<ul class="an-sub" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}"
th:classappend="${sub.id == currentCategoryId} ? 'active'">
<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>
</div>
</nav>
</header>
</html>
@@ -0,0 +1,205 @@
<!--
academic-navy(学府藏蓝)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
结构复用主题 main.css 第 7/9 节既有语汇(an-block / an-block-head--bar / an-block-item / an-date-chip /
an-feat-* / an-top-badge / an-empty),仅布局模式特有的排布(双栏、速递条、图墙、链接行)另起第 16 节。
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background),
与回落模式 categoryBlocks 兼容;区块标题一律取 category.name(两种模式都有值)。
-->
<!-- 形态一:双栏要闻(homeStyle=news_grid)——左 3 份头条图文、右 2 份紧凑行(综合类高校官网 60/40) -->
<section class="an-section an-container" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="an-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="an-block an-block--flow"
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">校园要闻</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<div class="an-split" th:if="${lead != null}">
<div class="an-block-featured">
<a th:href="${url.article(lead.id)}">
<span class="an-feat-cover" th:if="${lead.coverImage != null}">
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
</span>
<span class="an-feat-title" th:text="${lead.title}">头条标题</span>
<span class="an-feat-summary" th:if="${!#strings.isEmpty(lead.summary)}"
th:text="${lead.summary}">摘要</span>
<span class="an-feat-meta">
<span class="an-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</a>
</div>
<ul class="an-block-list" th:if="${rest != null}">
<li class="an-block-item" th:each="item : ${rest}">
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="an-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:默认卡(homeStyle 未设/未知)——整宽头条 + 行式列表 -->
<section class="an-section an-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<div class="an-block-featured" th:if="${!#lists.isEmpty(block.items)}"
th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="an-feat-cover an-feat-cover--row" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="an-feat-title" th:text="${feat.title}">头条标题</span>
<span class="an-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
<span class="an-feat-meta">
<span class="an-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</a>
</div>
<ul class="an-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="an-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="an-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:日期方块列表(homeStyle=compact)——官网最典型的「方块日期 + 标题」行式 -->
<section class="an-section an-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">通知公告</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-block-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="an-block-item" th:each="item : ${block.items}">
<span class="an-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列行式 -->
<section class="an-section an-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">媒体聚焦</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-block-list an-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="an-block-item" th:each="item : ${block.items}">
<span class="an-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<time class="an-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,四列格 -->
<section class="an-section an-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">图片新闻</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="an-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="an-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="an-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="an-container an-ticker-inner">
<span class="an-ticker-label" th:text="${block.title}">要闻速递</span>
<ul class="an-ticker-list">
<li th:each="item : ${block.items}">
<a class="an-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery) -->
<section class="an-section an-container an-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
th:if="${!#lists.isEmpty(block.items)}">
<div class="an-block-head">
<h2 th:text="${block.title}">线上展厅</h2>
<i class="an-block-tick" aria-hidden="true"></i>
</div>
<p class="an-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
Online Exhibition Hall
</p>
<ul class="an-wall an-wall--4">
<li class="an-wall-item" th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
<span class="an-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:友情链接分组(block.linkGroups 由渲染端按 group 过滤) -->
<section class="an-section an-container" th:fragment="links(block)" aria-label="友情链接">
<div class="an-block an-block--flow">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.title}">友情链接</h2>
</div>
<th:block th:each="grp : ${block.linkGroups}">
<p class="an-links-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
<ul class="an-links">
<li th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
</li>
</ul>
</th:block>
</div>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="an-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="an-empty-hint">内容发布后将在此展示</p>
</div>
@@ -0,0 +1,149 @@
<!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="an-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸见 theme.json bannerSize) -->
<section class="an-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="an-banner-slides">
<a class="an-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="an-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="an-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="an-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="an-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>
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:each="block : ${layoutBlocks}">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<section class="an-section an-container" aria-label="头条与图片新闻">
<div class="an-headline">
<div class="an-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
<div class="an-block-head">
<h2>头条推荐</h2>
<i class="an-block-tick" aria-hidden="true"></i>
</div>
<ul class="an-headline-list">
<li class="an-headline-lead" th:with="lead=${topArticles[0]}">
<a class="an-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="an-lead-meta">
<span class="an-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</li>
<li th:each="item, stat : ${topArticles}" th:if="${stat.index > 0}">
<span class="an-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>
</div>
<div class="an-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="an-block-head">
<h2>图片新闻</h2>
<i class="an-block-tick" aria-hidden="true"></i>
</div>
<a class="an-focus-lead" th:with="cover=${focusArticles[0]}"
th:href="${url.article(cover.id)}">
<span class="an-focus-cover">
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
</span>
<span class="an-focus-title" th:text="${cover.title}">图片新闻标题</span>
</a>
<ul class="an-focus-rows">
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
<a th:href="${url.article(row.id)}">
<span class="an-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
<span class="an-row-title" th:text="${row.title}">图片新闻标题</span>
</a>
</li>
</ul>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8) -->
<section class="an-section an-container" aria-label="栏目区块">
<div class="an-blocks">
<div class="an-block" th:each="block : ${categoryBlocks}">
<div class="an-block-head an-block-head--bar">
<h2 th:text="${block.category.name}">栏目名</h2>
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
</div>
<ul class="an-block-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="an-block-featured" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="an-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="an-feat-body">
<span class="an-feat-title" th:text="${feat.title}">头条标题</span>
<span class="an-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="an-feat-meta">
<span class="an-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
<li class="an-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="an-date-chip" th:if="${item.publishTime != null}"
th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="an-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="an-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,105 @@
<!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="an-page an-container" id="main-content">
<div class="an-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="an-side-nav-list">
<li class="an-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="an-page-card">
<nav class="an-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="an-crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="an-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="an-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="an-crumb-sep" aria-hidden="true">/</span>
<span class="an-crumb-current">搜索结果</span>
</nav>
<h1 class="an-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="an-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="an-article-list">
<!-- 有封面:图文行;无封面:行式(符号 + 标题 + 右侧日期) -->
<li th:each="item : ${listData.items}">
<div class="an-article-row" th:if="${item.coverImage != null}">
<a class="an-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="an-item-body">
<div class="an-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="an-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="an-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="an-article-line" th:if="${item.coverImage == null}">
<i class="an-line-tick" aria-hidden="true"></i>
<div class="an-line-main">
<a class="an-line-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="an-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
</div>
<div class="an-line-side">
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
</li>
</ul>
<div class="an-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="an-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="an-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="an-pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
<a th:classappend="${cur <= 1} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
th:aria-disabled="${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:aria-label="${'第' + p + '页'}"
th:text="${p}">1</a>
<a th:classappend="${cur >= total} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
th:aria-disabled="${cur >= total}">下一页</a>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="an-page an-container" id="main-content">
<div class="an-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="an-side-nav-list">
<li class="an-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="an-page-card">
<nav class="an-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="an-crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="an-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<article class="an-article">
<h1 class="an-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="an-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="an-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="an-empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,23 @@
{
"specVersion": 1,
"name": "academic-navy",
"title": "学府藏蓝(academic-navy)",
"version": "1.0.0",
"author": "yunhai",
"description": "学府藏蓝·高校官网门户:藏蓝顶栏+白底主导航+金线标题+日期方块列表+双栏要闻(左图右列)+深色三段页脚。基座 scholar-blue,配色与首页区块形态按综合类高校官网重做。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--an-primary", "label": "主色", "type": "color", "default": "#0b4a8f" },
{ "key": "primaryDark", "cssVar": "--an-primary-dark", "label": "主色加深", "type": "color", "default": "#063161" },
{ "key": "primaryLight", "cssVar": "--an-primary-light","label": "主色提亮", "type": "color", "default": "#1a6cb5" },
{ "key": "accent", "cssVar": "--an-accent", "label": "点缀金", "type": "color", "default": "#b8934a" },
{ "key": "navBg", "cssVar": "--an-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" },
{ "key": "footerBg", "cssVar": "--an-footer-bg", "label": "页脚底色", "type": "color", "default": "#0a2b4d" },
{ "key": "text", "cssVar": "--an-text", "label": "正文文字色", "type": "color", "default": "#2b3644" },
{ "key": "textLight", "cssVar": "--an-text-light", "label": "次要文字色", "type": "color", "default": "#6b7889" },
{ "key": "bg", "cssVar": "--an-bg", "label": "页面底色", "type": "color", "default": "#f1f4f9" },
{ "key": "cardBg", "cssVar": "--an-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--an-border", "label": "边框色", "type": "color", "default": "#dbe3ee" }
]
}
@@ -0,0 +1,58 @@
# agri-forest · 农林绿
云海 CMS 皮肤包。fork 自 `default`(数据契约、模板 th:* 绑定与 default 完全一致),视觉层按农林院校门户语言全部重写,设计语言参考信阳农林学院官网(仅学语言,不复制实现)。
## 风格定位
- **气质词**:庄重、质朴、农林学府。
- **配色**:墨绿 `#1C6E19` 主导(通栏导航/日期方块/页脚主底),加深端 `#14510F`(页脚/标题条),提亮端 `#3FA43A`(当前导航项/hover),麦穗金 `#C8A45C` 只做装饰线与角标(栏目标题下划线、导航当前项下杠、日期方块 hover 金线、置顶角标)。
- **结构**:白色页头(左校徽+校名,右工具行:今日日期 + 校园直通车下拉 + 站内搜索)→ 墨绿通栏主导航(白字,当前栏目提亮绿底+金下杠,导航下方与 banner 无缝)→ 通栏轮播(左下墨绿标题条带金边)→ 首页区块 → 墨绿多列页脚(更深底条)。
- **工艺**:小圆角 4px;间距刻度 4/8/12/16/24/32/48;字号阶梯 12/13/14/15/16/18/20/24/28/32;hover 过渡 0.2s ease;正文行高 1.75;无渐变堆砌、无粉紫、无玻璃拟态;图标一律内联 SVG。
## 参数取值建议(theme.json,13 项)
| key | cssVar | 默认 | 建议 |
| --- | --- | --- | --- |
| primary | `--af-primary` | `#1C6E19` | 主色。换季校庆可微调为 `#1F7A1B`,勿低于 `#14510F` 明度(导航白字对比) |
| primaryDark | `--af-primary-dark` | `#14510F` | 页脚主底/轮播标题条。与 primary 保持同色相 1~2 档差 |
| primaryLight | `--af-primary-light` | `#3FA43A` | 当前导航项/hover。需要更醒目可用 `#4DB548`,过亮会刺眼 |
| accent | `--af-accent` | `#C8A45C` | 麦穗金装饰线/角标。庄重场合可降饱和为 `#B8934A` |
| navBg | `--af-nav-bg` | `#1C6E19` | 主导航底色。默认与 primary 一致;要"导航深一档"配 `#14510F` |
| footerBg | `--af-footer-bg` | `#14510F` | 页脚主底;底条固定更深一档(`--af-primary-deep`,非参数) |
| text / textLight | `--af-text` / `--af-text-light` | `#2A3327` / `#6C7A67` | 正文/次要文字,偏绿灰 |
| bg | `--af-bg` | `#F5F8F3` | 页面底色,带一点绿调的暖白 |
| cardBg | `--af-card-bg` | `#FFFFFF` | 卡片底,建议保持纯白 |
| border | `--af-border` | `#DDE8DA` | 边框/分隔线,绿灰调 |
| linkGreen | `--af-link` | `#277428` | 正文链接绿,介于主色深浅之间 |
| listLayout | —(select) | `date` | 列表页形态:`date`=46px 日期方块行(农林官网典型);`plain`=通栏行式(日期右置),信息密度更高 |
微调优先级:站点 `theme_params`(本表参数)→ 站点 `custom_css` → 本包 main.css(契约 §6)。
## 首页九段形态表(fragments/home-blocks.html)
站点配 `home_layout` 时首页正文由本包 fragment 渲染(换肤必须连正文一起换):
| fragment | 触发 | 形态 |
| --- | --- | --- |
| `news_grid` | article_list + homeStyle=news_grid | 三图焦点(横排 3 封面卡)+ 双列要闻行式列表;支持站点背景带(news_bg_image,画在卡片之下) |
| `media_focus` | article_list + homeStyle=media_focus | 学术动态:双列密行 + 墨绿来源徽标 |
| `compact` | article_list + homeStyle=compact | 通知公告:46px 墨绿日期方块(日大号+年月小号),行 hover 出金线 |
| `image_wall` | article_list + homeStyle=image_wall | 四列图片墙(仅有封面条目),悬浮标题 |
| `card` | article_list 其他/未设 | 默认卡:头条图文(有封面则图文)+ 行式列表 |
| `ticker` | type=ticker | 要闻速递横条:墨绿底、金色分隔、单行不塌列 |
| `gallery` | type=gallery | 校园映像:居中标题+副题+四列图墙+悬浮标题 |
| `links` | type=links | 专题网站:4 列缩略卡(绿标题条+金下杠+圆徽),分组拉平 |
| `empty` | 各形态 items 为空 | 空态「栏目暂无内容」(守卫包在外层 th:block,th:replace 优先级 100 先于 th:if 300) |
未知类型回落 `themes/common` 通用骨架;`:root` 已把 `--af-*` 别名到契约变量(`--primary/--text/--card-bg/--border/--bg/--radius`),兜底骨架与 themeVarsCss 换色可穿透。
## 纪律要点
- URL 一律 `url.*` 生成,无硬编码绝对地址;非转义输出仅契约七处白名单。
- 中文防塌列:`.af-ticker-list{min-width:0;white-space:nowrap}`、`.af-ticker-item{flex:none;text-overflow:ellipsis}`、`.af-block-item-title{min-width:0;overflow:hidden;text-overflow:ellipsis}`。
- 网格一律 `minmax(0,1fr)`;≤768px 单列、导航横滚、子菜单静态平铺。
- 背景带层序:`.af-sec-bg` z-index:0,其后 `.af-block` z-index:1。
## 截图说明
`assets/screenshot.png` 为按本皮肤设计语言绘制的门户结构示意图(非浏览器实拍)。管理端选择器如需实拍图,起服后截首页替换即可(记入遗留项)。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,87 @@
/* agri-forest 皮肤包脚本:页头日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘),无任何外部依赖 */
(function () {
/* 页头工具行日期(本地时间,YYYY年M月D日 星期X) */
var dateEl = document.getElementById('siteDate');
if (dateEl) {
var now = new Date();
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
+ now.getDate() + '日 星期' + week;
}
/* 轮播 */
var root = document.querySelector('.af-banner');
var slides = document.querySelectorAll('.af-banner-slide');
var dots = document.querySelectorAll('.af-banner-dots button');
var prevBtn = document.querySelector('.af-banner-arrow.prev');
var nextBtn = document.querySelector('.af-banner-arrow.next');
if (!slides.length || !root) {
return;
}
var current = 0;
var timer = null;
var INTERVAL = 5000;
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() {
stop();
if (slides.length > 1) {
timer = window.setInterval(function () {
show(current + 1);
}, INTERVAL);
}
}
function stop() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
dots.forEach(function (dot, index) {
dot.addEventListener('click', function () {
show(index);
start();
});
});
if (prevBtn) {
prevBtn.addEventListener('click', function () {
show(current - 1);
start();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', function () {
show(current + 1);
start();
});
}
root.addEventListener('mouseenter', stop);
root.addEventListener('mouseleave', start);
root.addEventListener('focusin', stop);
root.addEventListener('focusout', start);
root.setAttribute('tabindex', '-1');
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') {
show(current - 1);
start();
} else if (e.key === 'ArrowRight') {
show(current + 1);
start();
}
});
start();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

@@ -0,0 +1,76 @@
<!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="af-page af-container" id="main-content">
<div class="af-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="af-side-nav-list">
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="af-page-card">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a class="af-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="af-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="af-crumb-current">正文</span>
</nav>
<article class="af-article">
<h1 class="af-article-title" th:text="${article.title}">文章标题</h1>
<div class="af-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="af-article-content" th:utext="${article.content}">正文</div>
</article>
<nav class="af-sibling" aria-label="上下篇导航">
<a th:if="${prevArticle != null}" class="prev"
th:href="${url.article(prevArticle.id)}">
<span class="sib-label">上一篇</span>
<span class="sib-title" th:text="${prevArticle.title}">上一篇标题</span>
</a>
<span class="sib-none prev" th:if="${prevArticle == null}">
<span class="sib-label">上一篇</span>
<span class="sib-title">已经是最早一篇</span>
</span>
<a th:if="${nextArticle != null}" class="next"
th:href="${url.article(nextArticle.id)}">
<span class="sib-label">下一篇</span>
<span class="sib-title" th:text="${nextArticle.title}">下一篇标题</span>
</a>
<span class="sib-none next" th:if="${nextArticle == null}">
<span class="sib-label">下一篇</span>
<span class="sib-title">已是最新一篇</span>
</span>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共页脚(农林绿·墨绿多列):品牌区(校徽+站名+社媒图标) + 地址/联系/版权/备案 + 二维码位
+ 友链分组(浅色带)+ 更深底条(校名)。
数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName),
增强键(docs/12):footerAddressLines / qr_wechat / qr_weibo / social_*(有值才渲染,空站点优雅降级)。 -->
<footer class="af-footer" th:fragment="footer">
<!-- 主区:墨绿多列 -->
<div class="af-footer-main">
<div class="af-container af-footer-cols">
<!-- 品牌列 -->
<div class="af-footer-brand">
<div class="af-footer-logo-row">
<img th:if="${site.config['logo'] != null}" class="af-footer-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<span class="af-footer-site" th:text="${site.name}">站点名称</span>
</div>
<!-- 社媒主页(url 校验,有值才渲染对应图标;图标内联 SVG,契约 §4.6 不引外链) -->
<div class="af-social" th:if="${site.config['social_wechat'] != null
or site.config['social_weibo'] != null
or site.config['social_douyin'] != null
or site.config['social_bilibili'] != null}">
<a th:if="${site.config['social_wechat'] != null}" class="af-social-icon"
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/></svg>
</a>
<a th:if="${site.config['social_weibo'] != null}" class="af-social-icon"
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/></svg>
</a>
<a th:if="${site.config['social_douyin'] != null}" class="af-social-icon"
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/></svg>
</a>
<a th:if="${site.config['social_bilibili'] != null}" class="af-social-icon"
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M7.2 4.3 9 6.4h6l1.8-2.1c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/></svg>
</a>
</div>
</div>
<!-- 地址/联系列(多校区地址按行直出;contact/copyright/beian 可空,空块收敛) -->
<div class="af-footer-info">
<ul class="af-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
</ul>
<p class="af-footer-line" th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
<p class="af-footer-line" th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
<p class="af-footer-line" th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
</div>
<!-- 二维码位(asset 校验,有值才渲染) -->
<div class="af-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
<figure class="af-qr" th:if="${site.config['qr_wechat'] != null}">
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
<figcaption>官方微信</figcaption>
</figure>
<figure class="af-qr" th:if="${site.config['qr_weibo'] != null}">
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
<figcaption>官方微博</figcaption>
</figure>
</div>
</div>
</div>
<!-- 友链区(浅绿带;无分组的站整段不渲染;分组无链接时防御性跳过——数据上分组由链接行物化,不可达) -->
<div class="af-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
<div class="af-container af-footer-links-inner">
<div class="af-links-group" th:each="group : ${site.linkGroups}"
th:if="${!#lists.isEmpty(group.links)}">
<span class="af-group-name" th:text="${group.groupName}">分组</span>
<p class="af-links-row">
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
<!-- B4 友链 Logo:有 logo 图片+名称并排(渲染期补基准),无 logo 纯文字 -->
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="af-link-logo"
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
<span th:text="${link.name}">链接</span>
</a>
</p>
</div>
</div>
</div>
<!-- 底条(更深墨绿):全站无任何配置信息时仍保留结构完整(空块由 CSS 收敛) -->
<div class="af-footer-bottom">
<div class="af-container af-footer-bottom-inner">
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
th:text="${site.schName}">学校名称</p>
</div>
</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),与 default 数据契约一致 -->
<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,66 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 公共头部(农林绿):白色页头(左校徽+校名 / 右工具行:日期+校园直通车下拉+搜索)
+ 墨绿通栏主导航(白字,当前栏目 primaryLight 底),导航下方与 banner 无缝衔接。
数据契约与 default 完全一致:topbar 日期由 main.js 填充、专业院系下拉(site.colleges 空则不渲染)。 -->
<header class="af-header" th:fragment="header">
<a class="af-skip-link" href="#main-content">跳到主要内容</a>
<!-- 白色页头 -->
<div class="af-head">
<div class="af-head-inner">
<!-- 左:校徽 + 校名 -->
<div class="af-brand">
<img th:if="${site.config['logo'] != null}" class="af-logo"
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="af-sitename" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<!-- 右:工具行(今日日期 + 校园直通车 + 站内搜索);无子站站点下拉整块不渲染 -->
<div class="af-head-side">
<span class="af-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
<div class="af-colleges" th:if="${!site.colleges.isEmpty()}"
th:with="collegesLabel=${site.config['colleges_label'] ?: '校园直通车'}">
<span class="af-colleges-toggle">
<a href="javascript:void(0)" th:text="${collegesLabel}">校园直通车</a>
<i class="af-colleges-arrow" aria-hidden="true">▾</i>
</span>
<ul class="af-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="af-search" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</div>
</div>
</div>
<!-- 墨绿通栏主导航:外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
<nav class="af-nav" aria-label="栏目导航">
<ul>
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
<a th:href="${url.home()}">首页</a>
</li>
<li th:each="cat : ${navCategories}"
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
<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)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="af-caret" aria-hidden="true">▾</i></a>
<!-- 二级下拉:白底面板(.af-sub-nav 样式见 assets/css/main.css);当前项高亮(docs/11) -->
<ul class="af-sub-nav" th:if="${!cat.children.isEmpty()}">
<li th:each="sub : ${cat.children}"
th:classappend="${sub.id == currentCategoryId} ? 'active'">
<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,257 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!--
agri-forest(农林绿)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
九段形态:news_grid(三图焦点+要闻列表)/ media_focus(来源徽标密行)/ compact(46px 日期方块行)/
image_wall(四列图片墙)/ card(默认卡:头条+行式)/ ticker(速递横条)/ gallery(校园映像图廊)/
links(专题网站缩略卡)/ empty(空态)。
契约:block 为渲染端装配的同构 Map(type/title/category.name/homeStyle/items/moreUrl/subtitle/
background/source/linkGroups),与回落模式 categoryBlocks 兼容;区块标题一律取 category.name。
空态守卫必须包在 th:block 外层:th:replace(优先级 100)先于 th:if(300)求值,写在同一标签上等于没写。
-->
<!-- 形态一:三图焦点 + 要闻列表(homeStyle=news_grid)——横排 3 张封面卡 + 双列行式列表 -->
<section class="af-section af-section--news" th:fragment="news_grid(block)" aria-label="栏目区块"
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
<div class="af-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
<div class="af-block af-container">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M4 5h11a1 1 0 0 1 1 1v13H6a2 2 0 0 1-2-2V5z"/><path d="M16 9h3a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2"/><path d="M7 9h6M7 13h6M7 17h4"/></svg>
</span>
<h2 th:text="${block.category.name}">学校要闻</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<div class="af-news-body" th:if="${!#lists.isEmpty(block.items)}"
th:with="rest=${#lists.size(block.items) > 3 ? block.items.subList(3, block.items.size()) : null}">
<ul class="af-trio">
<li class="af-trio-item" th:each="item, stat : ${block.items}" th:if="${stat.index &lt; 3}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<span class="af-trio-cover" th:if="${item.coverImage != null}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
</span>
<span class="af-trio-cover af-trio-ph" th:if="${item.coverImage == null}" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M5 19C5 9 12 4 20 4c0 9-5 15-12 15-1.5 0-3-.4-3-1z"/><path d="M5 19c3-5 6-8 10-10"/></svg>
</span>
<span class="af-trio-cap">
<span class="af-trio-title" th:text="${item.title}">焦点标题</span>
<span class="af-trio-meta">
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<ul class="af-block-list af-block-list--two" th:if="${rest != null}">
<li class="af-line" th:each="item : ${rest}">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态二:来源徽标密行(homeStyle=media_focus)——学术动态,双列紧凑行 + 来源徽标 -->
<section class="af-section af-container" th:fragment="media_focus(block)" aria-label="栏目区块">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M9 3h6M10 3v6L4.6 18A2 2 0 0 0 6.3 21h11.4a2 2 0 0 0 1.7-3L14 9V3"/><path d="M7.5 15h9"/></svg>
</span>
<h2 th:text="${block.category.name}">学术动态</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-block-list af-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
<li class="af-line af-line--media" th:each="item : ${block.items}">
<span class="af-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">来源</span>
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态三:46px 日期方块列表(homeStyle=compact)——通知公告:日大号 + 年月小号,墨绿底白字,hover 金线 -->
<section class="af-section af-container" th:fragment="compact(block)" aria-label="栏目区块">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 10v4l6 1 9 5V4L9 9l-6 1z"/><path d="M7 15l1.5 5h3L10 16"/></svg>
</span>
<h2 th:text="${block.category.name}">通知公告</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-block-list af-notice-list" th:if="${!#lists.isEmpty(block.items)}">
<li class="af-line af-line--date" th:each="item : ${block.items}">
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 形态四:四列图片墙(homeStyle=image_wall)——仅取有封面的条目,悬浮标题 -->
<section class="af-section af-container" th:fragment="image_wall(block)" aria-label="栏目区块">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
</span>
<h2 th:text="${block.category.name}">图片新闻</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-wall" th:if="${!#lists.isEmpty(block.items)}">
<li class="af-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
<span class="af-wall-cap" th:text="${item.title}">图片标题</span>
</a>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</section>
<!-- 形态五:默认卡(homeStyle 未设/未知)——整宽头条(有封面则图文)+ 行式列表 -->
<section class="af-section af-container" th:fragment="card(block)" aria-label="栏目区块">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M6 3h8l4 4v14H6V3z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>
</span>
<h2 th:text="${block.category.name}">栏目名</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<div class="af-feat" th:if="${!#lists.isEmpty(block.items)}" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="af-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
</span>
<span class="af-feat-body">
<span class="af-feat-title" th:text="${feat.title}">头条标题</span>
<span class="af-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="af-feat-meta">
<span class="af-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</div>
<ul class="af-block-list" th:if="${#lists.size(block.items) > 1}">
<li class="af-line" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
<th:block th:if="${#lists.isEmpty(block.items)}">
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
</th:block>
</div>
</section>
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
<section class="af-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
th:if="${!#lists.isEmpty(block.items)}">
<div class="af-container af-ticker-inner">
<span class="af-ticker-label">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 10v4l6 1 9 5V4L9 9l-6 1z"/><path d="M7 15l1.5 5h3L10 16"/></svg>
<span th:text="${block.title}">要闻速递</span>
</span>
<ul class="af-ticker-list">
<li th:each="item : ${block.items}">
<a class="af-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
</li>
</ul>
</div>
</section>
<!-- 布局模式:校园映像图廊(block.items = 轮播位 gallery,四列图片墙 + 悬浮标题) -->
<section class="af-section af-container af-gallery" th:fragment="gallery(block)" aria-label="校园映像"
th:if="${!#lists.isEmpty(block.items)}">
<div class="af-gallery-head">
<div class="af-block-title af-gallery-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
</span>
<h2 th:text="${block.title}">校园映像</h2>
</div>
<p class="af-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
Campus Gallery
</p>
</div>
<ul class="af-wall af-wall--4">
<li class="af-wall-item" th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
<span class="af-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
</a>
</li>
</ul>
</section>
<!-- 布局模式:专题网站缩略卡(block.linkGroups 由渲染端按 group 过滤;4 列卡片含标题条) -->
<section class="af-section af-container" th:fragment="links(block)" aria-label="专题网站">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</span>
<h2 th:text="${block.title}">专题网站</h2>
</div>
</div>
<ul class="af-topic-grid">
<th:block th:each="grp : ${block.linkGroups}">
<li class="af-topic" th:each="l : ${grp.links}">
<a th:href="${l.linkUrl}" target="_blank" rel="noopener" th:title="${l.name}">
<span class="af-topic-bar" aria-hidden="true"></span>
<span class="af-topic-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14 0 18M12 3c-3 3.5-3 14 0 18"/></svg>
</span>
<span class="af-topic-name" th:text="${l.name}">专题名称</span>
</a>
</li>
</th:block>
</ul>
</section>
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
<div class="af-empty" th:fragment="empty(block)">
<p>栏目暂无内容</p>
<p class="af-empty-hint">内容发布后将在此展示</p>
</div>
</html>
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理" -->
<main class="af-page af-container" id="main-content">
<div class="af-page-card af-svc-guide">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a th:href="${url.serviceList()}">服务中心</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<span class="af-crumb-current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
</nav>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="af-empty af-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="af-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<article class="af-svc-guide-body" th:if="${!svcUnavailable}">
<h1 class="af-article-title" th:text="${item.title}">事项名称</h1>
<div class="af-article-meta">
<span th:if="${!#strings.isEmpty(item.deptName)}"
th:text="${'办理部门:' + item.deptName}">办理部门</span>
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
<time th:if="${item.publishTime != null}"
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
</div>
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
<div class="af-svc-apply" th:if="${applyUrl != null}">
<a class="af-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
<span class="af-svc-apply-hint">进入服务大厅在线办理</span>
</div>
<p class="af-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
<table class="af-svc-info">
<caption class="af-skip-link">事项基本信息</caption>
<tbody>
<tr th:if="${!#strings.isEmpty(item.objectName)}">
<th scope="row">服务对象</th>
<td th:text="${item.objectName}">服务对象</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
<th scope="row">事项类别</th>
<td th:text="${item.categoryName}">事项类别</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
<th scope="row">办理地点</th>
<td th:text="${item.handlePlace}">办理地点</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
<th scope="row">办理时限</th>
<td th:text="${item.handleTimeLimit}">办理时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
<th scope="row">承诺时限</th>
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
<th scope="row">咨询电话</th>
<td th:text="${item.contactPhone}">咨询电话</td>
</tr>
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
<th scope="row">监督投诉</th>
<td th:text="${item.supervisePhone}">监督投诉电话</td>
</tr>
</tbody>
</table>
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
<h2>办理流程</h2>
<div class="af-article-content" th:utext="${item.processHtml}">办理流程</div>
</section>
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
<h2>所需材料</h2>
<div class="af-article-content" th:utext="${item.materialHtml}">所需材料</div>
</section>
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
<h2>注意事项</h2>
<div class="af-article-content" th:utext="${item.noticeHtml}">注意事项</div>
</section>
<section class="af-svc-section af-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
<h2>相关材料下载</h2>
<ul>
<li th:each="att : ${attachments}">
<a th:href="${url.upload(att.webUrl)}" th:download
th:text="${att.fileName}">材料文件</a>
<span class="af-svc-att-size" th:if="${att.fileSize != null}"
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
</li>
</ul>
</section>
<nav class="af-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
</nav>
</article>
<aside class="af-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
<h2 class="af-section-title">相关服务</h2>
<ul class="af-svc-related-list">
<li th:each="rel : ${relatedItems}">
<a th:href="${rel.url}">
<span class="af-svc-related-name" th:text="${rel.title}">相关事项</span>
<span class="af-svc-related-meta">
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
<span class="af-svc-tag"
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
</span>
</a>
</li>
</ul>
</aside>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,169 @@
<!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="af-home" id="main-content">
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize):
干净切图 + 左下墨绿标题条 -->
<section class="af-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
<div class="af-banner-slides">
<a class="af-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="af-banner-title" th:text="${b.title}">标题</span>
</a>
</div>
<button class="af-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
<button class="af-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
<div class="af-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>
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:each="block : ${layoutBlocks}">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
</th:block>
</th:block>
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 三图焦点:图片新闻(focusArticles 必有封面)横排 3 卡 -->
<section class="af-section af-container" aria-label="图片新闻" th:if="${!#lists.isEmpty(focusArticles)}">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
</span>
<h2>图片新闻</h2>
</div>
</div>
<ul class="af-trio">
<li class="af-trio-item" th:each="item : ${focusArticles}">
<a th:href="${url.article(item.id)}" th:title="${item.title}">
<span class="af-trio-cover">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</span>
<span class="af-trio-cap">
<span class="af-trio-title" th:text="${item.title}">图片新闻标题</span>
<span class="af-trio-meta">
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
</section>
<!-- 头条区:头条推荐(置顶优先)大卡 + 右侧密行 -->
<section class="af-section af-container" aria-label="头条推荐">
<div class="af-headline" th:if="${!#lists.isEmpty(topArticles)}">
<div class="af-block">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M4 5h11a1 1 0 0 1 1 1v13H6a2 2 0 0 1-2-2V5z"/><path d="M16 9h3a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2"/><path d="M7 9h6M7 13h6M7 17h4"/></svg>
</span>
<h2>头条推荐</h2>
</div>
</div>
<div class="af-headline-body"
th:with="rest=${#lists.size(topArticles) > 1 ? topArticles.subList(1, topArticles.size()) : null}">
<div class="af-headline-lead" th:with="lead=${topArticles[0]}">
<a class="af-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
<div class="af-lead-meta">
<span class="af-top-badge" th:if="${lead.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${lead.publishTime != null}"
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
<ul class="af-block-list" th:if="${rest != null}">
<li class="af-line" th:each="item : ${rest}">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8,D1-2):通知公告形态(46px 日期方块) -->
<section class="af-section af-container" aria-label="栏目区块">
<div class="af-blocks">
<div class="af-block" th:each="block : ${categoryBlocks}">
<div class="af-block-head">
<div class="af-block-title">
<span class="af-block-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M6 3h8l4 4v14H6V3z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>
</span>
<h2 th:text="${block.category.name}">栏目名</h2>
</div>
<a class="af-more" th:href="${block.moreUrl}">更多 &gt;</a>
</div>
<ul class="af-block-list af-notice-list" th:if="${!#lists.isEmpty(block.items)}">
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
<li class="af-feat-row" th:with="feat=${block.items[0]}">
<a th:href="${url.article(feat.id)}">
<span class="af-feat-cover" th:if="${feat.coverImage != null}">
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
</span>
<span class="af-feat-body">
<span class="af-feat-title" th:text="${feat.title}">头条标题</span>
<span class="af-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
th:text="${feat.summary}">摘要</span>
<span class="af-feat-meta">
<span class="af-top-badge" th:if="${feat.topmost == 1}">置顶</span>
<time class="af-item-date" th:if="${feat.publishTime != null}"
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
<li class="af-line af-line--date" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
</li>
</ul>
<div class="af-empty" th:if="${#lists.isEmpty(block.items)}">
<p>栏目暂无内容</p>
<p class="af-empty-hint">内容发布后将在此展示</p>
</div>
</div>
</div>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,111 @@
<!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>
<!-- 列表页(含搜索结果页两用):themeParams['listLayout'] 切换形态——
date=46px 日期方块行(默认,农林官网典型)/ plain=通栏行式(隐藏方块、日期右置) -->
<main class="af-page af-container" id="main-content"
th:classappend="${themeParams['listLayout'] == 'plain'} ? 'af-page--plainlist'">
<div class="af-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="af-side-nav-list">
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="af-page-card">
<nav class="af-crumb" aria-label="面包屑" th:if="${searchMode != true}">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node, st : ${categoryPath}">
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a th:if="${!st.last}" class="af-crumb-parent" th:href="${url.category(node.id)}"
th:text="${node.name}">父栏目</a>
<span class="af-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
</th:block>
</nav>
<nav class="af-crumb" aria-label="面包屑" th:if="${searchMode == true}">
<a th:href="${url.home()}">首页</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<span class="af-crumb-current">搜索结果</span>
</nav>
<h1 class="af-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
<h1 class="af-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
<ul class="af-article-list">
<!-- 有封面:图文行;无封面:日期方块行(plain 形态下自动切通栏行式),不造占位图 -->
<li th:each="item : ${listData.items}">
<div class="af-article-row" th:if="${item.coverImage != null}">
<a class="af-item-cover" th:href="${url.article(item.id)}">
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
</a>
<div class="af-item-body">
<div class="af-item-title">
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
</div>
<p class="af-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
<div class="af-item-meta">
<time th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
</div>
</div>
</div>
<div class="af-article-line" th:if="${item.coverImage == null}">
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
<b th:text="${#temporals.format(d, 'dd')}">01</b>
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
</span>
<div class="af-line-main">
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
<p class="af-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
th:text="${item.summary}">摘要</p>
</div>
<div class="af-line-side">
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
<time class="af-line-date" th:if="${item.publishTime != null}"
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</div>
</div>
</li>
</ul>
<div class="af-empty" th:if="${#lists.isEmpty(listData.items)}">
<p th:if="${searchMode == true}">未找到相关内容</p>
<p th:if="${searchMode != true}">栏目暂无内容</p>
<p class="af-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
<p class="af-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
</div>
<!-- 分页(列表页/搜索页共用) -->
<nav class="af-pagination" th:if="${listData.totalPages > 1}"
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
<a th:classappend="${cur <= 1} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
th:aria-disabled="${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:aria-label="${'第' + p + '页'}"
th:text="${p}">1</a>
<a th:classappend="${cur >= total} ? 'disabled'"
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
th:aria-disabled="${cur >= total}">下一页</a>
</nav>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query -->
<main class="af-page af-container" id="main-content">
<div class="af-page-card af-svc-page">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<span class="af-crumb-sep" aria-hidden="true">/</span>
<span class="af-crumb-current">服务中心</span>
</nav>
<h1 class="af-page-title">服务中心</h1>
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
<div class="af-empty af-svc-unavailable" th:if="${svcUnavailable}">
<p>事项服务暂时不可用,请稍后再试</p>
<p class="af-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
</div>
<div class="af-svc-filters"
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
<span class="af-svc-filter-label">服务对象</span>
<div class="af-svc-filter-options">
<a class="af-svc-chip" th:each="opt : ${serviceFilters.objects}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
<span class="af-svc-filter-label">事项类别</span>
<div class="af-svc-filter-options">
<a class="af-svc-chip" th:each="opt : ${serviceFilters.categories}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
<span class="af-svc-filter-label">服务部门</span>
<div class="af-svc-filter-options">
<a class="af-svc-chip" th:each="opt : ${serviceFilters.depts}"
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
</a>
</div>
</div>
</div>
<div class="af-svc-toolbar" th:if="${!svcUnavailable}">
<form class="af-svc-search" th:action="${url.serviceList()}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
<button type="submit">搜索</button>
</form>
<a class="af-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
</div>
<ul class="af-svc-list" th:if="${!svcUnavailable}">
<li th:each="it : ${serviceList.items}">
<a class="af-svc-item" th:href="${url.serviceItem(it.id)}">
<span class="af-svc-item-cover" th:if="${it.coverImage != null}">
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
</span>
<span class="af-svc-item-body">
<span class="af-svc-item-title">
<span class="af-svc-item-name" th:text="${it.title}">事项名称</span>
<span class="af-svc-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
<span class="af-svc-tag"
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
</span>
<span class="af-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
th:text="${it.summary}">简介</span>
<span class="af-svc-item-meta">
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
<time th:if="${it.publishTime != null}"
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
</span>
</span>
</a>
</li>
</ul>
<div class="af-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
<p class="af-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
<p class="af-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
</div>
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
<nav class="af-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
<a th:each="p : ${serviceList.pageUrls}"
th:classappend="${p.active} ? 'active'"
th:href="${p.url}"
th:aria-label="${'第' + p.page + '页'}"
th:aria-current="${p.active} ? 'page'"
th:text="${p.page}">1</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!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="af-page af-container" id="main-content">
<div class="af-cols">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="af-side-nav-list">
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="af-page-card">
<nav class="af-crumb" aria-label="面包屑">
<a th:href="${url.home()}">首页</a>
<th:block th:each="node : ${categoryPath}">
<span class="af-crumb-sep" aria-hidden="true">/</span>
<a class="af-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span class="af-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
<span class="af-crumb-current">正文</span>
</nav>
<article class="af-article">
<h1 class="af-article-title" th:text="${category.name}">栏目名</h1>
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
<div class="af-article-content" th:if="${!#strings.isEmpty(category.content)}"
th:utext="${category.content}">单页内容</div>
<div class="af-empty" th:if="${#strings.isEmpty(category.content)}">
<p>页面暂无内容</p>
<p class="af-empty-hint">内容编辑后将在此展示</p>
</div>
</article>
</div>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
<script th:src="${url.asset('js/main.js')}"></script>
</body>
</html>
@@ -0,0 +1,30 @@
{
"specVersion": 1,
"name": "agri-forest",
"title": "农林绿(agri-forest)",
"version": "1.0.0",
"author": "yunhai",
"description": "墨绿+麦穗金的农林院校风(设计语言参考信阳农林学院官网)。",
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--af-primary", "label": "主色(墨绿)", "type": "color", "default": "#1C6E19" },
{ "key": "primaryDark", "cssVar": "--af-primary-dark", "label": "主色加深", "type": "color", "default": "#14510F" },
{ "key": "primaryLight", "cssVar": "--af-primary-light","label": "主色提亮", "type": "color", "default": "#3FA43A" },
{ "key": "accent", "cssVar": "--af-accent", "label": "麦穗金", "type": "color", "default": "#C8A45C" },
{ "key": "navBg", "cssVar": "--af-nav-bg", "label": "主导航底色", "type": "color", "default": "#1C6E19" },
{ "key": "footerBg", "cssVar": "--af-footer-bg", "label": "页脚底色", "type": "color", "default": "#14510F" },
{ "key": "text", "cssVar": "--af-text", "label": "正文文字色", "type": "color", "default": "#2A3327" },
{ "key": "textLight", "cssVar": "--af-text-light", "label": "次要文字色", "type": "color", "default": "#6C7A67" },
{ "key": "bg", "cssVar": "--af-bg", "label": "页面底色", "type": "color", "default": "#F5F8F3" },
{ "key": "cardBg", "cssVar": "--af-card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" },
{ "key": "border", "cssVar": "--af-border", "label": "边框色", "type": "color", "default": "#DDE8DA" },
{ "key": "linkGreen", "cssVar": "--af-link", "label": "链接绿", "type": "color", "default": "#277428" },
{ "key": "listLayout", "label": "列表页形态", "type": "select", "default": "date",
"options": [
{ "value": "date", "label": "日期方块" },
{ "value": "plain", "label": "通栏行式" }
]
}
]
}
@@ -342,3 +342,29 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
margin-right: 4px;
border-radius: 2px;
}
/* ===== 左侧栏目导航(docs/11):有二级节点的模块,栏目/文章页左侧展示兄弟目录 ===== */
.list-main.has-sidenav, .detail-main.has-sidenav { display: flex; align-items: flex-start; gap: 24px; }
.has-sidenav .list-body { flex: 1; min-width: 0; }
.side-nav { flex: none; width: 220px; background: var(--card-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
.side-nav-head { background: var(--primary); color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 18px 12px; letter-spacing: 2px; }
.side-nav-list { list-style: none; margin: 0; padding: 0; }
.side-nav-item + .side-nav-item { border-top: 1px dashed var(--border); }
.side-nav-item a { display: block; padding: 12px 16px; color: var(--text); text-decoration: none; font-size: 15px; transition: background .15s ease, color .15s ease; }
.side-nav-item a:hover { color: var(--primary); background: var(--bg); }
.side-nav-item.active a { background: var(--primary); color: #fff; font-weight: 600; text-decoration: none; }
/* 顶导航二级下拉当前项高亮(docs/11 顺带) */
.site-nav ul.sub-nav li.active a { color: var(--primary); }
@media (max-width: 768px) {
.list-main.has-sidenav, .detail-main.has-sidenav { flex-direction: column; gap: 12px; }
.side-nav { width: 100%; }
.side-nav-list { display: flex; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.side-nav-item { flex: none; }
.side-nav-item + .side-nav-item { border-top: none; border-left: 1px dashed var(--border); }
.side-nav-item a { white-space: nowrap; }
}
@media print {
.side-nav { display: none; }
.list-main.has-sidenav, .detail-main.has-sidenav { display: block; }
}
@@ -4,10 +4,25 @@
<body>
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
<main class="detail-main">
<main class="detail-main" th:classappend="${sideNav != null} ? 'has-sidenav'">
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
<aside class="side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
<div class="side-nav-head" th:text="${sideNav.top.name}">模块名</div>
<ul class="side-nav-list">
<li class="side-nav-item" th:each="item : ${sideNav.items}"
th:classappend="${item.id == sideNav.activeId} ? 'active'">
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
th:text="${item.name}">子栏目</a>
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
</li>
</ul>
</aside>
<div class="list-body">
<nav class="breadcrumb">
<a th:href="${url.home()}">首页</a>
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</a>
<th:block th:each="node : ${categoryPath}">
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
</th:block>
<span>正文</span>
</nav>
@@ -35,6 +50,7 @@
th:href="${url.article(nextArticle.id)}"
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
</nav>
</div>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
@@ -29,18 +29,19 @@
</div>
<nav class="site-nav">
<ul>
<li th:classappend="${currentCategoryId == null} ? 'active'">
<li th:classappend="${currentCategoryId == null and navServiceActive == 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' : '')}">
th:classappend="${((currentCategoryId == cat.id or navTopId == 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}">
<li th:each="sub : ${cat.children}"
th:classappend="${sub.id == currentCategoryId} ? 'active'">
<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>
@@ -21,6 +21,16 @@
</div>
</section>
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</th:block>
</th:block>
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
<th:block th:unless="${layoutBlocks != null}">
<!-- 置顶/头条区(cms_article topmost 优先) -->
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
<h2 class="section-title">头条推荐</h2>
@@ -49,6 +59,7 @@
</ul>
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
</section>
</th:block>
</main>
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>

Some files were not shown because too many files have changed in this diff Show More