Compare commits

...
32 Commits
Author SHA1 Message Date
ops b9719009b3 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:32 +00:00
ops 02bb3ac96a ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:04 +00:00
chenxing fe75784d13 [CMS-REWORK] #4 保存恒被拦(模板实参ref被解包致validate恒false): refs改脚本侧取用+validate返回首错精确文案
build / build (push) Successful in 2m40s
2026-09-29 11:03:47 +08:00
chenxing 01d91ddd4b [CMS-REWORK] #3 区块序列右上角序号徽标(含隐藏不重排,与校验报错第N个对齐) 2026-09-29 10:54:39 +08:00
chenxing 39dfc645ff [CMS-REWORK] #2 Tab假脏: seeding窗口覆盖异步flush的watcher + 子组件emitUp no-op守卫 2026-09-29 10:53:03 +08:00
chenxing 4208f8ca89 [CMS-REWORK] #1 首页布局编辑器数据栏目为空: 栏目树加载与首用自动编排解耦(挂载+siteId watch双通道幂等加载) 2026-09-29 10:49:49 +08:00
chenxing cff4325947 [CMS-VISUAL] 站点管理改版: 表格→站点工作台(主站宽卡+院系子站卡片网格)+ 页头承载站点身份 + v3.1 视觉修订(182 测试绿)
build / build (push) Successful in 2m20s
- SiteManage.vue: 8 列表格改为站点工作台,按 schId 分组「主站宽卡 + 院系子站网格」,
  保留卡片⇄表格视图切换与搜索过滤;scope=all 全局视图支持多校分组,搜索时主站卡始终保留(层级锚点)
- 新增 views/cms/components/SiteCard.vue、SiteMainCard.vue
- 删除 components/SiteContextCard.vue: 站点身份改由页头「学校名.站点名」承担(SchoolLayout),
  侧栏不再铺第二份站点列表,切站入口从 3 处收敛到 2 处(面包屑 + 站点工作台)
- v3.1 视觉修订: 色带语义统一为「当前站」(全页同时只有一张卡带选中态,不再一页两处蓝);
  删除面包屑 band-sub 橙色接缝条(身份已收回页头);返回按钮 ios-undo 18×18 → ios-arrow-back 26×26 可点方块;
  删除 SiteManage .zone-tip 设计说明文字(误当 UI 渲染)
- 测试: siteLayer.spec.ts 站点卡 4 用例重写为页头反向/正向断言,siteManage.spec.ts 补卡片视图断言;182 用例绿
- 方案: docs/22-站点管理修订方案.md(含 §9 v3.1 视觉修订与像素对照表)
2026-09-28 18:32:13 +08:00
chenxing b6b022e4ca [CMS-VISUAL] E 前端配套: 编辑下架主题站点时按别名表呈现归一主题(与后端 DEPRECATED_ALIASES 同构,不误报未注册,181 测试绿)
build / build (push) Successful in 2m20s
2026-09-28 15:04:02 +08:00
chenxing 6334b24d34 [CMS-VISUAL] F5: 页脚结构示意图(六处位置标注+实时回显)+ favicon 1:1/二维码 4:3 占位框与比例预览(180 测试绿) 2026-09-28 14:59:05 +08:00
chenxing 49678a6568 [CMS-VISUAL] D1-D10: 组件库全量——布局模型扩 6 新类型(stats_band/featured_topics/video_tour/events/people/badges)+ 编辑器新属性面板(统计项/专题卡/徽章数组编辑器、图标选择器)+ gallery 瀑布流 + banner 4 Tab(topic/event 位)+ 人物管理 CRUD 页 + 全类型英文副题(176 测试绿) 2026-09-28 13:48:51 +08:00
chenxing 1a913490ef [CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿) 2026-09-28 12:52:57 +08:00
chenxing af451703e7 [CMS-VISUAL] F1: 站点设置 Tab 化(7 Tab 独立表单/dirty/保存,增量合并互不连带)+ 新建渐进式向导(两步后全开放,无灰态)+ 自定义样式独立 Tab + 页脚/基础展示字段视觉位置说明(165 测试绿) 2026-09-28 12:39:21 +08:00
chenxing 010fb39e85 [CMS-VISUAL] F4: Banner 管理改「首页轮播/线上展厅」双 Tab,各 Tab 独立新增/尺寸提示,弹窗归属锚定打开时 Tab 2026-09-28 12:33:39 +08:00
chenxing 3a0f1e0cc9 [CMS-VISUAL] F6: 站点设置「扩展参数」分区退役(7→6 分区),未知配置键改静默保留不丢失(vitest 164 绿) 2026-09-28 12:33:23 +08:00
chenxing 649a08629b [CMS-VISUAL] F3: 栏目管理移除「首页展示形态」下拉,形态统一归首页布局编辑器配置 2026-09-28 12:32:58 +08:00
chenxing 31cbec152a feat(category): S9 内容管理 IA 切分——栏目页降级为内容概览 + 首页形态误导文案修正
build / build (push) Successful in 2m20s
- 新建 components/category/CategoryContentSummary.vue(194):列表型栏目右侧不再挂文章表格,
  改「共 N 篇 + 最近 5 行 + 查看全部」概览;计数口径=直属+子孙(与门户 collectListDescendantIds
  同语义)、全状态计入,一次 page 请求同时拿 total 与 5 行(零新接口、零后端改动)
- CategoryManage.vue 列表分支换组件 + 头注更新;编辑弹窗「首页展示形态」提示改为
  「展示形态由当前皮肤包的主题化实现决定,未实现的形态回落通用骨架渲染」(v2 §七 验收第 8 条点名项)
- ArticleListPanel.vue +7:setup 读 route.query.categoryId 作为初始筛选(深链回显)
- types/cms.ts 1 行:ArticlePageQuery.categoryIds 放宽为 number|string(逗号串),手册未点名、已在
  audit-D §六 披露为偏差
- categoryWorkbench.spec.ts 15→17 例:T-D-01 按裁决改写旧断言(概览而非表格,并机器证明无操作列)、
  T-D-02 深链回显、T-D-03 查看全部跳转;夹具上提 + beforeEach 复位,默认值与旧硬编码行为逐字一致
- 未动 router/routes.ts 的 meta.subOf(护栏 siteRouter.spec 8 / siteLayer.spec 16 绿)
- 遗留立案:R-43 fixedCategory/extraHeight 无生产调用方(按纪律未删)/ R-44 概览含子孙而深链只筛直属
  需产品定口径 / R-45 菜单名来自平台模块表 / R-46 ?categoryId 仅读一次
- 前端 166/166 绿 + build:base exit=0;S10 复跑同绿
2026-09-22 21:55:23 +08:00
chenxing 5d048abdae feat(site-layer): S8 站点上下文卡 + 子站身份染色(方案 A,零后端改动)
- 新建 components/SiteContextCard.vue(194):数据源 store.schoolSites(含 SiteVo.theme),
  展示学校›站点层级与当前站皮肤包名;切站沿用既有纯 URL 通道 adminPathOf,不新增状态机
- SchoolLayout.vue 挂载卡片到原空 .menu-top-box 槽 + 子站态身份染色(侧栏淡底 #fff7ec、
  卡区顶 3px 橙条、页头胶囊转橙);主站态不染色
- 硬约束:未动 utils/sitePath.ts 的 stripSiteSeg/withSiteSeg(剥段高亮全靠它)、未动 router/routes.ts
- siteLayer.spec.ts +4 例(纯加,SITES 夹具补 theme 字段);护栏 siteRouter.spec(8)/sitePath.spec(14)/
  menuRoute.spec(5) 原样绿
- 遗留立案:R-39 isAlone 档不可见 / R-40 门户地址未展示 / R-41 面包屑是否退役 SiteSwitcher 待裁决 /
  R-42 显示包 code 而非中文名
- 前端 164/164 绿 + build:base exit=0
2026-09-22 21:54:58 +08:00
chenxing d05272b820 feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s
2026-09-22 18:34:50 +08:00
chenxing abccca4397 feat(admin): 站点设置页三处体验修复 + 主题参数横排色卡 + 新皮肤 mock
build / build (push) Successful in 2m19s
用户验收反馈三处:
1. 左侧锚点导航不固定——根因 .base-wrap-main .main-container 的 legacy
   overflow:hidden 使 sticky 相对不滚动的隐藏滚动盒定位失效(实测滚动 600px
   导航 top=-387);本页运行时改写为 visible、离开还原,修复后滚动 800px
   导航稳定停在 top:72px。窄屏(≤1080)导航转横排胶囊。
2. 皮肤颜色参数竖排长蛇(真实主题 12 个色参数 = 12 行)——ThemeParamForm
   重排:color 参数转 150px 起步 auto-fill 色卡网格(宽屏一行 4~5 张,
   色卡含取色器+名称+HEX),select/image/text 转两列行式;非法值红框提示。
3. 「换肤/改参数保存后门户立即生效」提示框样式突兀且与按钮拥挤——移除
   皮肤区内联 form-hint,并入卡片 footer 的 card-hint(与基本信息卡一致)。
整页配套:分区卡 10px 圆角+柔和阴影+品牌竖条标题、锚点导航品牌指示条、
footer hint 自适应换行。
mock THEMES 补 poly-blue/agri-forest/jade-normal 三套新皮肤条目。
vitest 147/147 绿;vite build 通过。
2026-09-20 02:42:07 +08:00
chenxing e5a9506228 chore(mock): 粉/紫两包下线后 mock 主题改指 academic-navy/campus-azure
build / build (push) Successful in 2m19s
后端删除 cherry-bloom/violet-iris,改以学院蓝为基座的学府藏蓝/青蓝校园两包接管;
mock 主题清单同步换名换标题换色(cssVar 用各皮肤私有前缀 --an-*/--cz-*),
院系子站种子站点 theme 一并改指,避免管理端主题选择器出现已不存在的皮肤。
2026-09-19 20:22:09 +08:00
chenxing be19ed066a fix(walkthrough): menuRoute 识别 /sch/{code} 壳路径免回退告警;栏目弹窗按钮 type=link 改 text;mock 补 cherry-bloom/violet-iris 主题
build / build (push) Successful in 2m20s
2026-09-19 17:47:51 +08:00
chenxing f29c193ea7 feat(theme+规范): 管理端接入 yh-theme 主题层并按 bsch 应用页规范升级页面骨架
- 主题层: scripts/gen-yh-theme.mjs 生成 assets/styles/yh-theme.css(构建期静态化 color-mix),
  5 主题档×侧栏深浅×渐变×side-plain 挂 html 档位类名; main.ts 于 icons.css 后引入并 initTheme() 消首帧闪色
- utils/theme.ts(+8 用例): localStorage(yh-theme) 持久化 + <html> 档位类名幂等切换
- ThemeSwitcher.vue(+6 用例): 顶栏配色胶囊(5 色网格+3 开关), 定稿 07-seam-pure 同款皮肤
- SchoolLayout: 顶栏 logo 徽标+配色切换器挂载; 站点身份色带退役, 子站橙由 yh-theme .band-sub::before 融合
- useTableHeight 响应化(+5 用例): ResizeObserver(body) 重算修复窗口缩放冻结, 常量 56/56 档不动
- bsch 规范骨架: 操作区 fix mb-12/次按钮 fr, Table 补 link-table table-bottom-box,
  文章关键字 200px 即输即搜(300ms 防抖去 enter-button), 附件分页 customPageCount 记忆+page-box fr,
  轮播展示位移入搜索区, 友链分组改 tabs-wrapper+Tabs card, 五表单校验失败统一"请填写红色星号内容!"
- 142 用例全绿, vite build 通过
2026-09-19 16:48:16 +08:00
chenxing 6f1f666479 周末修订
build / build (push) Successful in 4m47s
2026-09-15 23:41:11 +08:00
chenxing a05b0ded55 feat(svcitem): 事项库+办事大厅 R2 前端三页,并复用富文本组件消除双份实现
build / build (push) Successful in 3m14s
落地(依据《事项库与办事大厅详细设计 v1》§7)
- 路由:办事事项 modId=21 / 服务字典 modId=22 + 子站镜像(/sch/{code}/site/{siteCode}/svc/*)
  + 3 条 legacyRedirect 旧链兼容;SchoolLayout 菜单图标兜底
- ServiceItemList:三维筛选/关键字/分页/发布下架删除/跳转编辑
- ServiceItemEdit:三段富文本 + 封面 + 指南附件上传回显(attList)+「我要办理外链」提示
  + 部门选择器不可达时提示"可留空保存"
- ServiceDictManage:object/category 双 Tab,被引用键编辑锁定 + 删除引用守护提示
- api/类型:SvcItemApi/SvcDictApi 与 types/cms.ts 契约补齐;站点设置补 svc_hall_base 键

审计修复(docs/18)
- D3 接线:列表页新增 ServiceItemSortModal 拖拽排序(顺序源 selList,保存按下标重排 sort_no)
  与「同步部门名」按钮,此前后端 sort/syncDeptName 已备但前端无入口
- 附件场景拆分:封面与三个富文本编辑器改传 scene=svc_item_asset,指南附件保持 svc_item
  (内容资产由后端按内容派生归属,不再依赖前端收集上传 id——删除只写不读的 uploadedIds 记账)
- 部门组树:SvcDeptVo 增 children,前端 flattenDepts 缩进扁平化(后端改嵌套下发,交互不变)
- RichTextEditor 补 articleId/slateMinHeight prop 并去掉死状态 editorLoading;
  ArticleEdit 改为复用该组件(正文区最小高度按原口径传入,观感不变),消除双份 wangEditor 实现

测试与 mock
- mock 补 /api/svc/** 全量桩(事项分页/字典/部门树/排序/同步部门名/附件回显 + 站点 1、2 示例数据),
  dev:mock 下三页可用(实测 curl 各项响应正确)
- cms.spec 补 save/syncDeptName/selList/dict save+delete 契约;siteRouter.spec 补事项三条路由
  modId 与子站镜像 resolve 断言;新增 RichTextEditor.spec 5 例(上传参数三键/成功 emit/失败不 emit/工具栏排除项)
- vitest 127 用例全绿 + vite build 通过
2026-09-11 14:14:02 +08:00
chenxing 34733ec280 feat(cms): docs/16 站点设置整页化+首页布局编辑器- SiteSetting.vue整页7分区(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO与域名/扩展参数)锚点导航sticky+IntersectionObserver+分区卡片三段式每分区独立保存(configJson单一事实源+submitSection统一提交);创建模式仅①②可编辑其余锁定,创建成功原地replace到site/setting/:id;SiteWizard三步向导退役删除(门户预览收编portalPreviewUrl);SiteManage瘦身表格+行内编辑跳整页(adminPathOf子站带站点段);ImageAssetField图片双通道(上传+路径粘贴折叠);SiteConfigForm支持groups/showExtras/siteId+asset字段接ImageAssetField+三旧键表单不再展示(ABSORBED_LEGACY_KEYS静默保留);homeLayout三件套(homeLayout.ts模型parse/serialize/validate/genFromCategories一键生成+blockTypes.ts注册表showIf条件字段+HomeLayoutEditor.vue序列编辑增删显隐上移下移);routes site/new+site/setting/:id+legacy redirect;CategoryManage home_style提示改口指向首页布局;新增siteSetting.spec+homeLayout.spec,重写siteManage.spec,117用例全绿
build / build (push) Successful in 2m19s
2026-09-10 15:09:10 +08:00
chenxing 7c925cd4f2 ci修订:构建容器名唯一化(修复与同宿主其他仓并发构建时 exit 137 互杀)-runner 挂宿主 docker.sock,容器名在整个 daemon 上共享命名空间,固定名 frontbuild 在多仓/多 workflow 并发时会被对方的 docker rm -f 直接 SIGKILL(2026-09-10 三仓同推:本仓 build run#10 与 UserBase 两条 run 被打死、Notice 侥幸绿,本仓改动本身 vitest 99/99+build 已本地验证);改法 BUILD_CONTAINER=frontbuild-${GITHUB_RUN_ID}-${时间戳},run/exec/cp/rm 全步统一引用;YAML 解析与 step sh -n 通过
build / build (push) Successful in 2m24s
2026-09-10 12:17:01 +08:00
chenxing dfd2b89fbd feat(icons): 全量接入 ysch-icons 并统一首页入口-icons.css 由 build_mask_css.py 注入 105 个官方矢量(同名覆盖 apps/logout/category/browse/edit/ashbin/user-filling,异名保留 attachment/stop/saoba/print/word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi 12 个手绘稿,旧文件自动存 icons.css.bak);SchoolLayout 左侧菜单 Ionicons→iconfont 掩码类(wenzhang/liebiao/tupian/cshxd/sjlm/zhandian,page 与 modId 两分支同表,链接/附件库内无语义取近似)+菜单图标与文字间距 8px;面包屑补常驻首页段(超管 首页>学校列表>校名>站点>模块,校级无学校列表段),crumb-home 用 inline-flex 真居中并关掉压缩 layout.css 遗留的 .path-nav .icon-category{top:1px} 字体基线补偿(该补偿对 mask 的 1em 方块有害,不改压缩产物);头部「常用应用」改「返回首页」(icon-shouye+linkHomePage,面板 appList 原为硬编码 [] 属死入口,DOM 与 isPanel 保留仅失去入口);ArticleListPanel 发布按钮 icon-apps→icon-fasong 语义纠正;首页与全部应用链接 /BschHome→/userhome/front/index.html(线上 BschHome 实测 404,userhome 200 且 hash 含 /apps);登出路由 /cas/logout→/logout 与 Notice/UserhomeFront 统一(/cas/logout 在 SSO 侧无效);vitest 99/99+build 通过
build / build (push) Failing after 6s
2026-09-10 11:57:36 +08:00
chenxing 6ee9ab5684 feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
build / build (push) Successful in 2m43s
2026-09-10 11:11:23 +08:00
chenxing 9ba4cb135a feat(theme): 皮肤包管理收敛平台级+命名表单-新顶层路由/theme-manage(meta.superOnly守卫+页面watch双兜底),ThemeManage升级全屏页复用新抽WorkbenchHeader(工作台同款深色顶栏backTo=/workbench),工作台超管工具行加皮肤包管理按钮,删SiteManage兜底按钮,旧/theme重定向新页;上传/添加皮肤包改表单弹窗(zip+显示名/描述;添加=源包+标识名+命名一步),内置包可改显示名/描述(无参数区+恢复默认名称);openEdit未注册主题保留原值并告警(themeEdit util,不再静默归一default);mock删modId=7菜单节点;SiteWizard预览内容源v-model对齐path语义(W4遗留);vitest 62/62+build通过
build / build (push) Successful in 2m17s
2026-09-08 20:25:57 +08:00
chenxing 604d77049b refactor(build): 产物直出 dist-去掉 dist/base 中间层(outDir 统一 dist,与 UserBase 同风格),CI zip 步骤回归 cd dist,根治上提交 4b93b13 的打包路径绕法,vitest 56/56+本地 build dist 根 index.html 实测
build / build (push) Successful in 3m16s
2026-09-08 18:37:44 +08:00
chenxing 4b93b131c5 fix(ci): zip 打包路径改 dist/base-vite outDir 即 dist/base,曾误打 dist/ 整层致 zip 根只有 base/ 目录无 index.html,projman 前端发布校验「zip 根目录缺少 html 文件」(初版即错,首次真走部署通道才暴露)
build / build (push) Successful in 2m39s
2026-09-08 17:40:10 +08:00
chenxing 198175975b feat(env-config): 运行时配置改造-index.html内联引导脚本同步XHR拉/{ctx}/api/env-config?target=admin写__APP_CONFIG__,host.config三级合并(运行时JSON>VITE_*(仅dev/mock)>同域推导),删.env.base/.env.prod产物零环境值,vitest 56/56+build产物零烧入
build / build (push) Successful in 2m21s
2026-09-08 17:18:35 +08:00
81 changed files with 12252 additions and 1793 deletions
-8
View File
@@ -1,8 +0,0 @@
# 正式环境 - base
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
# API 基址用相对 "/yschcms"(同源 + context-path):projman 部署的服务上下文为 /yschcms,
# base 域网关按路径反代 /yschcms → 同源恒成立;
# 写死绝对域名会导致 localhost 直连、换域名等场景全部"加载中"卡死(day2 修复)
VITE_SERVICE_HOST=/yschcms
VITE_IS_MOCK=false
+4 -2
View File
@@ -1,5 +1,7 @@
# 开发环境 (dev):API 走 /api 同源相对路径(vite proxy 改写转发到本地后端 8072 的 /cms/api), # 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*(线上由 env-config 下发)。
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境 # API 走 /api 同源相对路径(serviceHost 留空 + vite proxy 改写转发到本地后端 8072 的 /cms/api),
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境。
# ⚠️ VITE_* 仅本地开发生效,线上一律走 /api/env-config?target=admin 运行时配置。
VITE_BASE_HOST=http://base.yunhaixingtong.com VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST= VITE_SERVICE_HOST=
-6
View File
@@ -1,6 +0,0 @@
# 生产环境(与 base 同源域名)
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
# 相对 "/yschcms"(同源 + context-path,projman 部署注入),理由见 .env.base 注释
VITE_SERVICE_HOST=/yschcms
VITE_IS_MOCK=false
+22 -9
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl / zip(alpine) - name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -38,24 +41,34 @@ jobs:
- name: 构建前端(node 容器) - name: 构建前端(node 容器)
run: | run: |
set -e set -e
docker rm -f frontbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上是共享
docker run -d --name frontbuild -w /build node:20 sleep 3600 # 命名空间。旧写法固定名 frontbuild 会在多仓/多条 workflow 并发时互相 docker rm -f 掉
docker cp "${PWD}/." frontbuild:/build/ # 对方正在构建的容器,表现为无编译报错的 exit 137(SIGKILL,2026-09-10 三仓同推即撞)。
docker exec frontbuild sh -c ' # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底(手动重跑/未注入时也不撞名)。
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c '
set -e set -e
cd /build cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci npm ci
npm run build npm run build
' '
echo "=== dist 产物 ===" echo "=== dist 产物 ==="
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20' docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录 # 从容器 cp dist 到宿主工作目录
docker cp frontbuild:/build/dist ./dist docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f frontbuild docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK" echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件) # 打包 dist → zip(vite outDir 已简化为直出 dist/,无中间层;进入 dist 打包,
# zip 根目录直接是 index.html/css/js。此前 outDir=dist/base 时曾误打 dist/ 整层,
# zip 根只有 base/ 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」)
- name: 打包产物 - name: 打包产物
run: | run: |
TAG=$(echo "$GITHUB_SHA" | cut -c1-8) TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
+24
View File
@@ -3,6 +3,30 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
(根路径,段数不足 2)则不设置:本地走 .env.dev/.env.mock(VITE_*),线上走同域推导兜底。
target = URL 第 2 段目录名:管理端产物部署于 /{contextPath}/admin/,故为 admin,
对应后端配置键 yh.env-config.source.admin。
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
<script>
(function () {
try {
var seg = location.pathname.split("/").filter(Boolean);
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev / .env.mock
var xhr = new XMLHttpRequest();
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
xhr.send(null);
if (xhr.status === 200) {
var conf = JSON.parse(xhr.responseText);
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
}
} catch (e) {
/* 拉取失败保持未设置,走同域推导兜底 */
}
})();
</script>
<title><%- title %></title> <title><%- title %></title>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" /> <link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script> <script src="./libs/jquery.min.js"></script>
+98 -2
View File
@@ -95,6 +95,50 @@ export interface MockLink {
status: number; status: number;
} }
export interface MockSvcDict {
id: number;
siteId: number;
dictType: "object" | "category";
dictKey: string;
dictName: string;
sortNo: number;
status: number;
}
export interface MockSvcItem {
id: number;
siteId: number;
itemCode: string;
title: string;
summary?: string;
objectKey?: string;
categoryKey?: string;
deptGroupId?: number;
deptName?: string;
handleMode: number;
onlineFlag: number;
handlePlace?: string;
handleTimeLimit?: string;
promiseTimeLimit?: string;
contactPhone?: string;
supervisePhone?: string;
processHtml?: string;
materialHtml?: string;
noticeHtml?: string;
faqCategoryId?: number;
noticeCategoryId?: number;
ruleCategoryId?: number;
coverImage?: string;
sortNo: number;
status: number;
publishTime?: string;
offlineTime?: string;
viewCount: number;
extJson?: string;
attachmentIds?: number[];
createTime: string;
}
export interface MockDb { export interface MockDb {
sites: MockSite[]; sites: MockSite[];
categories: MockCategory[]; categories: MockCategory[];
@@ -102,6 +146,8 @@ export interface MockDb {
attachments: MockAttachment[]; attachments: MockAttachment[];
banners: MockBanner[]; banners: MockBanner[];
links: MockLink[]; links: MockLink[];
svcItems: MockSvcItem[];
svcDicts: MockSvcDict[];
/** 上传文件缓存:url 尾段 → {buffer, mime} */ /** 上传文件缓存:url 尾段 → {buffer, mime} */
files: Map<string, { buffer: Buffer; mime: string; name: string }>; files: Map<string, { buffer: Buffer; mime: string; name: string }>;
} }
@@ -135,6 +181,8 @@ export function seedDb(): MockDb {
attachments: [], attachments: [],
banners: [], banners: [],
links: [], links: [],
svcItems: [],
svcDicts: [],
files: new Map(), files: new Map(),
}; };
@@ -183,7 +231,7 @@ export function seedDb(): MockDb {
schId: 1001, schId: 1001,
schName: "云海大学", schName: "云海大学",
name: "机电学院", name: "机电学院",
theme: "default", theme: "academic-navy",
configJson: JSON.stringify({ configJson: JSON.stringify({
public_base_url: "http://www.yunhai-demo.edu.cn/college/jdx", public_base_url: "http://www.yunhai-demo.edu.cn/college/jdx",
copyright: "机电学院 · Mock 数据", copyright: "机电学院 · Mock 数据",
@@ -199,7 +247,7 @@ export function seedDb(): MockDb {
schId: 1001, schId: 1001,
schName: "云海大学", schName: "云海大学",
name: "信息工程学院", name: "信息工程学院",
theme: "default", theme: "campus-azure",
configJson: JSON.stringify({ configJson: JSON.stringify({
public_base_url: "http://www.yunhai-demo.edu.cn/college/xxgc", public_base_url: "http://www.yunhai-demo.edu.cn/college/xxgc",
copyright: "信息工程学院 · Mock 数据", copyright: "信息工程学院 · Mock 数据",
@@ -373,6 +421,54 @@ export function seedDb(): MockDb {
l({ siteId: 2, groupName: "附中链接", name: "教育云平台", linkUrl: "https://edu.example.cn" }) l({ siteId: 2, groupName: "附中链接", name: "教育云平台", linkUrl: "https://edu.example.cn" })
); );
/* ---- 事项字典 + 办事事项(事项库 R2;demo 站为主,另给附中一条便于切换站点观察) ---- */
let sdid = 0;
const sd = (o: Partial<MockSvcDict> & { dictType: "object" | "category"; dictKey: string; dictName: string }) => ({
id: ++sdid,
siteId: 1,
sortNo: sdid,
status: 1,
...o,
} as MockSvcDict);
db.svcDicts.push(
sd({ dictType: "object", dictKey: "person", dictName: "个人" }),
sd({ dictType: "object", dictKey: "legal", dictName: "法人" }),
sd({ dictType: "object", dictKey: "other", dictName: "其他组织" }),
sd({ dictType: "category", dictKey: "jw", dictName: "教务服务" }),
sd({ dictType: "category", dictKey: "hq", dictName: "后勤服务" }),
sd({ dictType: "category", dictKey: "xs", dictName: "学生事务" }),
sd({ dictType: "category", dictKey: "stop", dictName: "已停用类别", status: 0 }),
sd({ siteId: 2, dictType: "object", dictKey: "person", dictName: "个人" }),
sd({ siteId: 2, dictType: "category", dictKey: "jw", dictName: "教务服务" })
);
let siid = 0;
const si = (o: Partial<MockSvcItem> & { itemCode: string; title: string }) => ({
id: ++siid,
siteId: 1,
handleMode: 1,
onlineFlag: 1,
sortNo: siid,
status: 1,
viewCount: Math.floor(Math.random() * 800),
processHtml: para("申请人携带材料到服务窗口提出申请。", "窗口受理后 1 个工作日内完成审核。"),
materialHtml: para("1. 申请表(现场领取)", "2. 本人身份证明", "3. 相关证明材料"),
noticeHtml: para("材料齐全当日办结;如需补正,以窗口告知为准。"),
extJson: JSON.stringify({ apply_url: "https://svhall.example.com/#/apply" }),
createTime: daysAgo(siid * 3),
...o,
} as MockSvcItem);
db.svcItems.push(
si({ itemCode: "BG-ZX-001", title: "转学办理", summary: "适用于在校学生转学申请", objectKey: "person", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 1, handleTimeLimit: "3 个工作日", contactPhone: "0571-88888888", publishTime: daysAgo(12) }),
si({ itemCode: "BG-XJ-002", title: "休学办理", summary: "因病或其他原因申请休学", objectKey: "person", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 3, handleTimeLimit: "5 个工作日", publishTime: daysAgo(10) }),
si({ itemCode: "BG-HQ-100", title: "宿舍报修", summary: "学生宿舍水电与设施报修", objectKey: "person", categoryKey: "hq", deptGroupId: 20, deptName: "后勤处", handleMode: 2, onlineFlag: 0, handlePlace: "后勤服务大厅", publishTime: daysAgo(8) }),
si({ itemCode: "BG-XS-210", title: "学生证补办", summary: "学生证遗失后的补办申请", objectKey: "person", categoryKey: "xs", deptGroupId: 30, deptName: "学生工作处", handleMode: 1, publishTime: daysAgo(6) }),
si({ itemCode: "BG-FR-300", title: "法人年检材料受理", summary: "面向法人单位的年检材料受理", objectKey: "legal", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 2, coverImage: "/mock-img/banner-3.svg", publishTime: daysAgo(4) }),
si({ itemCode: "BG-DRAFT-001", title: "校园卡挂失(草稿)", objectKey: "person", categoryKey: "xs", status: 0, handleMode: 1 }),
si({ itemCode: "BG-OFF-001", title: "教室借用申请(已下架)", objectKey: "person", categoryKey: "hq", status: 2, handleMode: 2, publishTime: daysAgo(20), offlineTime: daysAgo(2) }),
si({ siteId: 2, itemCode: "FZ-001", title: "附中转学办理", objectKey: "person", categoryKey: "jw", deptName: "附中教务处", status: 1, publishTime: daysAgo(5) })
);
return db; return db;
} }
+256 -5
View File
@@ -12,7 +12,7 @@
import type { Plugin } from "vite"; import type { Plugin } from "vite";
import type { IncomingMessage, ServerResponse } from "http"; import type { IncomingMessage, ServerResponse } from "http";
import { MockDb, categoryTree, categoryName, nextId, seedDb } from "./data"; import { MockDb, categoryTree, categoryName, nextId, seedDb } from "./data";
import { MockAttachment, MockBanner, MockLink, MockSite } from "./data"; import { MockAttachment, MockBanner, MockLink, MockSite, MockSvcDict, MockSvcItem } from "./data";
const db: MockDb = seedDb(); const db: MockDb = seedDb();
@@ -103,6 +103,76 @@ const THEMES: MockTheme[] = [
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#fff8f0" }, { key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#fff8f0" },
], ],
}, },
{
name: "academic-navy",
title: "学府藏蓝",
description: "高校官网门户:藏蓝顶栏 + 金线标题 + 日期方块列表 + 双栏要闻,适合综合类大学/工科学院。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/academic-navy/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--an-primary", label: "主色", type: "color", defaultValue: "#0b4a8f" },
{ key: "primaryDark", cssVar: "--an-primary-dark", label: "主色加深", type: "color", defaultValue: "#063161" },
{ key: "accent", cssVar: "--an-accent", label: "点缀金", type: "color", defaultValue: "#b8934a" },
],
},
{
name: "campus-azure",
title: "青蓝校园",
description: "高校官网门户:青蓝顶栏 + 居中双线标题 + 全宽封面头条 + 圆点密行,适合师范/农林类院校。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/campus-azure/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--cz-primary", label: "主色", type: "color", defaultValue: "#0d6a9e" },
{ key: "primaryDark", cssVar: "--cz-primary-dark", label: "主色加深", type: "color", defaultValue: "#084c73" },
{ key: "accent", cssVar: "--cz-accent", label: "点缀青绿", type: "color", defaultValue: "#2b9c8f" },
],
},
{
name: "poly-blue",
title: "理工蓝(poly-blue)",
description: "深宝蓝+活力橙的理工院校风:受众品牌行 + 白底吸顶导航 + 双栏要闻 + 日期方块 + 快捷通道宫格(参考浙江理工大学官网)。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/poly-blue/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--pb-primary", label: "主色", type: "color", defaultValue: "#0558A5" },
{ key: "primaryDark", cssVar: "--pb-primary-dark", label: "主色加深", type: "color", defaultValue: "#124489" },
{ key: "accent", cssVar: "--pb-accent", label: "活力橙", type: "color", defaultValue: "#FF7B00" },
],
},
{
name: "agri-forest",
title: "农林绿(agri-forest)",
description: "墨绿+麦穗金的农林院校风:白页头 + 墨绿通栏导航 + SVG 图标栏目标题 + 日期方块 + 专题缩略卡(参考信阳农林学院官网)。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/agri-forest/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--af-primary", label: "主色", type: "color", defaultValue: "#1C6E19" },
{ key: "primaryDark", cssVar: "--af-primary-dark", label: "主色加深", type: "color", defaultValue: "#14510F" },
{ key: "accent", cssVar: "--af-accent", label: "麦穗金", type: "color", defaultValue: "#C8A45C" },
],
},
{
name: "jade-normal",
title: "师大青(jade-normal)",
description: "翡翠青+暖橙点缀的师范院校风:橙色日期徽标 + 快捷通道图标宫格 + 融媒行(参考信阳师范大学官网)。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/jade-normal/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--jn-primary", label: "主色", type: "color", defaultValue: "#008D3F" },
{ key: "primaryDark", cssVar: "--jn-primary-dark", label: "主色加深", type: "color", defaultValue: "#04682E" },
{ key: "accent", cssVar: "--jn-accent", label: "暖橙", type: "color", defaultValue: "#F18D00" },
],
},
]; ];
function themeByName(name: string): MockTheme | undefined { function themeByName(name: string): MockTheme | undefined {
@@ -479,7 +549,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
mod(2106, 4, "友情链接", "/link"), mod(2106, 4, "友情链接", "/link"),
mod(2107, 5, "附件管理", "/attachment"), mod(2107, 5, "附件管理", "/attachment"),
mod(2108, 6, "站点管理", "/site"), mod(2108, 6, "站点管理", "/site"),
mod(2109, 7, "皮肤包管理", "/theme"), // 「皮肤包管理」已收敛为平台级页面 /theme-manage(超管工作台按钮进入),不再挂左侧菜单
])); ]));
return true; return true;
} }
@@ -626,14 +696,15 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
/* ---- 文章 ---- */ /* ---- 文章 ---- */
if (method === "GET" && path === "/api/article/page") { if (method === "GET" && path === "/api/article/page") {
const siteId = num(q(req, "siteId"), 0)!; const siteId = num(q(req, "siteId"), 0)!;
const categoryId = num(q(req, "categoryId")); // 与后端 ArticlePageQuery 契约一致:categoryIds(逗号分隔多选;栏目工作台传单值)
const categoryId = num(q(req, "categoryIds"));
const status = num(q(req, "status")); const status = num(q(req, "status"));
const keyword = q(req, "keyword") || ""; const keyword = q(req, "keyword") || "";
const page = num(q(req, "page"), 1)!; const page = num(q(req, "page"), 1)!;
const pageSize = num(q(req, "pageSize"), 10)!; const pageSize = num(q(req, "pageSize"), 10)!;
const categoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null; const filterCategoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null;
let rows = db.articles.filter(a => a.siteId === siteId); let rows = db.articles.filter(a => a.siteId === siteId);
if (categoryIds) rows = rows.filter(a => categoryIds.includes(a.categoryId)); if (filterCategoryIds) rows = rows.filter(a => filterCategoryIds.includes(a.categoryId));
if (status !== undefined) rows = rows.filter(a => a.status === status); if (status !== undefined) rows = rows.filter(a => a.status === status);
if (keyword) rows = rows.filter(a => a.title.includes(keyword) || a.summary.includes(keyword)); if (keyword) rows = rows.filter(a => a.title.includes(keyword) || a.summary.includes(keyword));
rows = [...rows].sort( rows = [...rows].sort(
@@ -854,6 +925,186 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
return true; return true;
} }
/* ---- 事项库:服务字典 + 服务部门 ---- */
if (method === "GET" && path === "/api/svc/dict/list") {
const siteId = num(q(req, "siteId"), 0)!;
const dictType = q(req, "dictType");
const rows = db.svcDicts
.filter(d => d.siteId === siteId && (!dictType || d.dictType === dictType))
.sort((x, y) => x.sortNo - y.sortNo || x.id - y.id)
.map(d => ({
...d,
referenced: db.svcItems.filter(i =>
d.dictType === "category" ? i.categoryKey === d.dictKey : i.objectKey === d.dictKey
).length,
}));
sendJson(res, ok(rows));
return true;
}
if (method === "POST" && path === "/api/svc/dict/save") {
const body = await readJson<Partial<MockSvcDict>>(req);
if (body.id) {
const i = db.svcDicts.findIndex(d => d.id === body.id);
if (i >= 0) db.svcDicts[i] = { ...db.svcDicts[i], ...body } as MockSvcDict;
sendJson(res, ok(db.svcDicts[i]?.id ?? null));
} else {
const row: MockSvcDict = {
id: nextId(db.svcDicts),
siteId: body.siteId!,
dictType: body.dictType || "object",
dictKey: body.dictKey || "",
dictName: body.dictName || "",
sortNo: body.sortNo ?? 0,
status: body.status ?? 1,
};
db.svcDicts.push(row);
sendJson(res, ok(row.id));
}
return true;
}
if (method === "POST" && path === "/api/svc/dict/delete") {
const { id } = await readJson<{ id: number }>(req);
db.svcDicts = db.svcDicts.filter(d => d.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "GET" && path === "/api/svc/dept/tree") {
// 模拟 UserBase 组树经 type 过滤后嵌套下发:学校 → 校内处室
sendJson(res, ok([
{
id: 100,
name: "云海大学",
children: [
{ id: 10, name: "教务处", children: [] },
{ id: 20, name: "后勤处", children: [] },
{ id: 30, name: "学生工作处", children: [] },
],
},
]));
return true;
}
/* ---- 事项库:办事事项管理 ---- */
if (method === "GET" && path === "/api/svc/item/page") {
const siteId = num(q(req, "siteId"), 0)!;
const status = q(req, "status");
const objectKey = q(req, "objectKey");
const categoryKey = q(req, "categoryKey");
const keyword = q(req, "keyword");
const page = num(q(req, "page"), 1)!;
const pageSize = num(q(req, "pageSize"), 10)!;
const dictName = (type: "object" | "category", key?: string) =>
key ? db.svcDicts.find(d => d.siteId === siteId && d.dictType === type && d.dictKey === key)?.dictName : undefined;
const filtered = db.svcItems
.filter(i => i.siteId === siteId)
.filter(i => (status === undefined || status === null || status === "" ? true : i.status === Number(status)))
.filter(i => (!objectKey ? true : i.objectKey === objectKey))
.filter(i => (!categoryKey ? true : i.categoryKey === categoryKey))
.filter(i => (!keyword ? true : i.title.includes(keyword) || i.itemCode.includes(keyword)))
.sort((x, y) => x.sortNo - y.sortNo || y.id - x.id);
const start = (page - 1) * pageSize;
sendJson(res, ok({
total: filtered.length,
list: filtered.slice(start, start + pageSize).map(i => ({
...i,
objectName: dictName("object", i.objectKey),
categoryName: dictName("category", i.categoryKey),
applyUrl: i.extJson ? (JSON.parse(i.extJson).apply_url as string) : undefined,
})),
}));
return true;
}
if (method === "GET" && path === "/api/svc/item/get") {
const id = num(q(req, "id"), 0)!;
const item = db.svcItems.find(i => i.id === id);
if (!item) {
sendJson(res, { code: 2, msg: "事项不存在", data: null });
return true;
}
sendJson(res, ok({
...item,
applyUrl: item.extJson ? (JSON.parse(item.extJson).apply_url as string) : undefined,
}));
return true;
}
if (method === "POST" && path === "/api/svc/item/save") {
const body = await readJson<Partial<MockSvcItem> & { applyUrl?: string }>(req);
const extJson = body.applyUrl ? JSON.stringify({ apply_url: body.applyUrl }) : undefined;
if (body.id) {
const i = db.svcItems.findIndex(x => x.id === body.id);
if (i >= 0) db.svcItems[i] = { ...db.svcItems[i], ...body, extJson } as MockSvcItem;
sendJson(res, ok(db.svcItems[i]?.id ?? null));
} else {
const row: MockSvcItem = {
id: nextId(db.svcItems),
siteId: body.siteId!,
itemCode: body.itemCode || "",
title: body.title || "",
handleMode: body.handleMode ?? 1,
onlineFlag: body.onlineFlag ?? 1,
sortNo: body.sortNo ?? 0,
status: 0,
viewCount: 0,
createTime: daysAgo(0),
...body,
extJson,
} as MockSvcItem;
db.svcItems.push(row);
sendJson(res, ok(row.id));
}
return true;
}
const svcStateMatch = path.match(/^\/api\/svc\/item\/(publish|offline)\/(\d+)$/);
if (method === "POST" && svcStateMatch) {
const item = db.svcItems.find(i => i.id === Number(svcStateMatch[2]));
if (item) {
item.status = svcStateMatch[1] === "publish" ? 1 : 2;
if (item.status === 1) item.publishTime = daysAgo(0);
else item.offlineTime = daysAgo(0);
}
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/delete") {
const { id } = await readJson<{ id: number }>(req);
db.svcItems = db.svcItems.filter(i => i.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/sort") {
const pairs = await readJson<{ id: number; sortNo: number }[]>(req);
(pairs || []).forEach(p => {
const item = db.svcItems.find(i => i.id === p.id);
if (item) item.sortNo = p.sortNo;
});
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/syncDeptName") {
sendJson(res, ok({ updated: 0 }));
return true;
}
if (method === "GET" && path === "/api/svc/item/selList") {
const siteId = num(q(req, "siteId"), 0)!;
const rows = db.svcItems
.filter(i => i.siteId === siteId)
.sort((x, y) => x.sortNo - y.sortNo || x.id - y.id)
.map(i => ({ id: i.id, itemCode: i.itemCode, title: i.title, status: i.status }));
sendJson(res, ok(rows));
return true;
}
if (method === "GET" && path === "/api/svc/item/attList") {
const siteId = num(q(req, "siteId"), 0)!;
const id = num(q(req, "id"), 0)!;
const ids = db.svcItems.find(i => i.id === id)?.attachmentIds || [];
sendJson(res, ok(
db.attachments
.filter(a => a.siteId === siteId && ids.includes(a.id))
.map(a => ({ id: a.id, fileName: a.fileName, webUrl: a.url, fileSize: a.fileSize }))
));
return true;
}
return false; return false;
} }
+249
View File
@@ -0,0 +1,249 @@
/**
* yh-theme.css 生成器(《管理端整体主题迁移方案.md》§6.4:color-mix 构建期静态化)。
* 定稿源:07-seam-pure.html(浅色接缝条·纯享版 2026-09-11)。
* 用法:node scripts/gen-yh-theme.mjs → src/assets/styles/yh-theme.css
* 运行时零 color-mix,兼容下限=支持 CSS 变量。
*/
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const hex = n => Math.round(n).toString(16).padStart(2, "0");
const parse = c => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
/** 与 CSS color-mix(in srgb, A w%, B) 等价的线性混合 */
const mix = (a, b, w) => {
const [r1, g1, b1] = parse(a);
const [r2, g2, b2] = parse(b);
return `#${hex(r1 * w + r2 * (1 - w))}${hex(g1 * w + g2 * (1 - w))}${hex(b1 * w + b2 * (1 - w))}`;
};
const rgba = (c, a) => {
const [r, g, b] = parse(c);
return `rgba(${r},${g},${b},${a})`;
};
// 定稿五档品牌色(07-seam-pure.html L31-35)
const THEMES = {
blue: { brand: "#577afb", b2: "#7b8ffd", dark: "#4264e4", soft: "#eef2ff", a: 0.34, glow: 0.42 },
ocean: { brand: "#2f7ce0", b2: "#4a9bf5", dark: "#2568c4", soft: "#eaf3fe", a: 0.34, glow: 0.42 },
ink: { brand: "#3d4d63", b2: "#6d82a1", dark: "#2e3c4f", soft: "#eef1f6", a: 0.34, glow: 0.5 },
violet: { brand: "#6d5ae0", b2: "#8f7cf2", dark: "#5a48c9", soft: "#f1effe", a: 0.34, glow: 0.42 },
teal: { brand: "#0d9488", b2: "#18b5a5", dark: "#0b7c72", soft: "#e8f7f5", a: 0.36, glow: 0.42 },
};
const NAVY = "#0d1424"; // side-deep 混色基底
const WHITE = "#ffffff";
const sideTokens = t => ({
deepSolid: mix(t.brand, NAVY, 0.72),
deepGradTop: mix(t.brand, WHITE, 0.96),
deepGradBot: mix(t.brand, NAVY, 0.7),
lightSolid: mix(t.brand, WHITE, 0.07),
lightGradTop: mix(t.brand, WHITE, 0.13),
lightGradBot: mix(t.brand, "#f5f7fa", 0.03),
lightLine: mix(t.brand, "#e6eaf0", 0.18),
lightHover: mix(t.brand, "#f2f4f8", 0.1),
lightActive: mix(t.brand, WHITE, 0.15),
strip: mix(t.brand, WHITE, 0.13), // 接缝条渐隐起点
});
const B = THEMES.blue;
const BS = sideTokens(B);
let css = `/* 本文件由 scripts/gen-yh-theme.mjs 生成(color-mix 已构建期静态化),勿手改派生色值。
定稿:07-seam-pure.html(浅色接缝条·纯享版);挂接:main.ts 置于 icons.css 之后。
档位开关挂在 <html>:data-theme=blue|ocean|ink|violet|teal × side-deep|side-light × grad-on|grad-off × side-plain */
/* ============ 变量体系(:root 默认档 = blue + side-deep + grad-on + side-follow) ============ */
:root{
--brand:${B.brand}; --brand-2:${B.b2}; --brand-dark:${B.dark};
--brand-soft:${B.soft}; --brand-a:${rgba(B.brand, B.a)}; --glow:${rgba(B.brand, B.glow)};
--topbar-bg:linear-gradient(100deg,var(--brand) 0%,var(--brand-2) 100%);
--green:#20a162; --green-d:#188451;
--red:#e54d42; --red-soft:#fef1f0;
/* 第一波(P1)沿用现状 56/56,紧凑化 52/44 留 P2(方案 §8-D2) */
--topbar-h:56px; --crumb-h:56px; --side-w:200px; --r:10px; --r-s:7px;
--page-bg:#f3f5fa; --panel:#fff; --line:#e9edf5; --line-2:#f0f3f9;
--text:#1e2635; --text-2:#5a6478; --text-3:#98a1b2;
--sh:0 1px 2px rgba(16,24,40,.04),0 14px 30px -22px rgba(16,24,40,.4);
--side-bg:linear-gradient(180deg,${BS.deepGradTop} 0%,${BS.deepGradBot} 100%);
--side-text:#c7d2ea; --side-icon:#95a6cb;
--side-line:rgba(255,255,255,.08);
--side-hover:rgba(255,255,255,.09); --side-hover-text:#fff;
--side-active-bg:rgba(255,255,255,.18); --side-active-text:#fff;
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
--strip-bg:linear-gradient(180deg,${BS.strip} 0,#fff 100%);
}
`;
// 每档主题:品牌 token + 侧栏底色四态 + 接缝条起点色(非底色 token 与主题无关,只写一次)
for (const [name, t] of Object.entries(THEMES)) {
const s = sideTokens(t);
css += `
/* ${name} · brand ${t.brand} */
html[data-theme="${name}"]{
--brand:${t.brand}; --brand-2:${t.b2}; --brand-dark:${t.dark};
--brand-soft:${t.soft}; --brand-a:${rgba(t.brand, t.a)}; --glow:${rgba(t.brand, t.glow)};
--strip-bg:linear-gradient(180deg,${s.strip} 0,#fff 100%);
}
html[data-theme="${name}"].side-deep{--side-bg:${s.deepSolid}}
html[data-theme="${name}"].side-deep.grad-on{--side-bg:linear-gradient(180deg,${s.deepGradTop} 0%,${s.deepGradBot} 100%)}
html[data-theme="${name}"].side-light{--side-bg:${s.lightSolid}}
html[data-theme="${name}"].side-light.grad-on{--side-bg:linear-gradient(180deg,${s.lightGradTop} 0%,${s.lightGradBot} 100%)}
html[data-theme="${name}"].grad-off{--topbar-bg:${t.brand}}
`;
}
css += `
/* ---- 深浅档非底色 token(与品牌色联动的用 var 派生;浅色档 line/hover/active 用默认档实测值,
主题间差异肉眼不可辨,保持单档以压复杂度——同定稿 side-light 组) ---- */
html.side-light{
--side-text:#4a5568; --side-icon:#98a2b3;
--side-line:${BS.lightLine};
--side-hover:${BS.lightHover}; --side-hover-text:#1e2635;
--side-active-bg:${BS.lightActive}; --side-active-text:var(--brand-dark);
--side-bar:var(--brand); --side-scroll:#d3d9e3;
}
html.grad-off{--topbar-bg:var(--brand)}
/* ---- 侧栏与品牌色解耦(side-follow 关) ---- */
html.side-plain.side-deep{
--side-bg:#2c3444; --side-text:#c2cbd9; --side-icon:#909dae;
--side-line:rgba(255,255,255,.08);
--side-hover:rgba(255,255,255,.08); --side-hover-text:#fff;
--side-active-bg:rgba(255,255,255,.16); --side-active-text:#fff;
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
}
html.side-plain.side-light{
--side-bg:#f7f8fa; --side-text:#4a5568; --side-icon:#98a2b3;
--side-line:#e6eaf0; --side-hover:#eff1f5;
--side-active-bg:#eceef3; --side-active-text:#2f3a4c;
--side-bar:#8b95a8; --side-scroll:#d3d9e3;
}
html.side-plain.grad-on.side-deep{--side-bg:linear-gradient(180deg,#333c4d 0%,#272e3c 100%)}
html.side-plain.grad-on.side-light{--side-bg:linear-gradient(180deg,#fafbfc 0%,#f2f4f7 100%)}
html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
`;
/* ============================ 皮肤层(§6.2 类名映射表,不改现有类名) ============================ */
css += `
/* ---- 顶栏:.header-container ---- */
.layout-wrap .header-container{background:var(--topbar-bg);box-shadow:0 1px 3px rgba(16,24,40,.14);transition:background .28s}
.layout-wrap .header-container h4{font-weight:600;letter-spacing:.4px}
.layout-wrap .header-container .delimiter{background:rgba(255,255,255,.28)}
/* 顶栏 logo 徽标(模板新增节点) */
.logo-badge{width:30px;height:30px;flex:0 0 auto;border-radius:9px;background:#fff;display:grid;place-items:center;box-shadow:0 2px 7px rgba(0,0,0,.16);margin-right:10px}
.logo-badge svg,.logo-badge img{width:22px;height:22px;display:block}
/* 用户胶囊 */
.layout-wrap .header-aside{padding:0 8px 0 5px;height:36px;border-radius:999px;transition:background .16s}
.layout-wrap .header-aside:hover{background:rgba(255,255,255,.16)}
/* ---- 侧栏:.left-menu(皮肤覆盖 layout.css 浅色档;深浅由 --side-* 驱动) ---- */
.layout-wrap .left-menu{background:var(--side-bg);border-right:1px solid var(--side-line);color:var(--side-text);transition:background .28s}
.layout-wrap .left-menu::-webkit-scrollbar{width:8px}
.layout-wrap .left-menu::-webkit-scrollbar-thumb{background:var(--side-scroll);border-radius:8px}
.layout-wrap .left-menu ul{padding:8px 0 18px}
.layout-wrap .left-menu li{margin:1px 8px;border-radius:var(--r-s);transition:background .15s,color .15s}
.layout-wrap .left-menu li a{
display:block;height:40px;line-height:40px;padding:0 12px;border-radius:var(--r-s);
color:var(--side-text);font-size:13px;border-right:none;transition:background .15s,color .15s;position:relative;
}
.layout-wrap .left-menu li a:hover{background:var(--side-hover);color:var(--side-hover-text)}
.layout-wrap .left-menu li a:hover .iconfont,.layout-wrap .left-menu li:hover .iconfont{color:var(--side-hover-text)}
.layout-wrap .left-menu li.active{background:var(--side-active-bg);border-right:0}
.layout-wrap .left-menu li.active a{color:var(--side-active-text);font-weight:600;background:transparent;border-right:none}
.layout-wrap .left-menu li.active a .iconfont{color:var(--side-active-text)}
.layout-wrap .left-menu li.active::before{
content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);
width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--side-bar);
}
.layout-wrap .left-menu li.child-item{margin:0 8px}
.layout-wrap .left-menu li.child-item a{height:36px;line-height:36px;font-size:12.5px}
.layout-wrap .left-menu li.child-item.active::before{height:16px}
/* 菜单图标 mask 类(icons.css 用 background-color 上色,这里给品牌感近色) */
.layout-wrap .left-menu .iconfont{margin-right:8px}
.layout-wrap .left-menu .arrow-up,.layout-wrap .left-menu .arrow-down{border-color:var(--side-icon)}
/* ---- 面包屑:.path-nav → 浅色接缝条(方案 §7.3 融合站点身份带) ---- */
.layout-wrap .path-nav{
height:var(--crumb-h);background:var(--strip-bg);border-bottom:1px solid var(--line);
color:var(--text-3);box-sizing:border-box;
}
.layout-wrap .path-nav .ivu-breadcrumb-item-link{color:var(--text-2)}
.layout-wrap .path-nav .ivu-breadcrumb>span:last-child .ivu-breadcrumb-item-link,.layout-wrap .path-nav .ivu-breadcrumb>span:last-child{color:var(--text);font-weight:600}
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */
.layout-wrap .path-nav::before{
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
}
.layout-wrap .path-nav.band-sub::before{background:#ff9900}
.layout-wrap .path-nav::after{display:none}
/* ---- 内容区:灰底 + 白卡 ---- */
.base-wrap-main{background:var(--page-bg)}
.base-wrap-main .main-container{
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
box-shadow:var(--sh);margin:0 18px 24px;padding:16px 20px 24px;box-sizing:border-box;
}
/* 栏目工作台左树卡 */
.base-wrap-main .tree-container{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh)}
/* ---- 搜索区分区化(卡片内 filterbar 语义) ---- */
.base-wrap-main .search-wrap{
background:#fbfcfe;border:1px solid var(--line-2);border-radius:var(--r-s);
padding:10px 12px;height:auto;min-height:52px;box-sizing:border-box;margin-bottom:12px;
}
/* ---- 表格/操作列皮肤(三蓝归一 #5979EA/#2d8cf0 → var(--brand)) ---- */
.ivu-table-header th,.ivu-table-fixed-header th{background:#f7f9fc !important;color:var(--text-2)}
.ivu-table td{border-bottom:1px solid var(--line-2)}
.ivu-table-body tr:hover td,.ivu-table tbody tr:hover td{background:var(--brand-soft) !important}
.base-wrap-main .ivu-table .operating-link{color:var(--brand)}
.base-wrap-main .ivu-table .operating-link.disabled{color:#aaa}
.ivu-table-cell .operating-btn:hover{background:var(--brand);color:#fff}
/* ---- view-ui-plus 品牌态 ---- */
.ivu-btn-primary{background-color:var(--brand);border-color:var(--brand)}
.ivu-btn-primary:hover,.ivu-btn-primary.active{background-color:var(--brand-dark);border-color:var(--brand-dark)}
.ivu-btn-success{background-color:var(--green);border-color:var(--green)}
.ivu-btn-success:hover,.ivu-btn-success.active{background-color:var(--green-d);border-color:var(--green-d)}
.ivu-btn-error{background-color:var(--red);border-color:var(--red)}
.ivu-btn-error:hover,.ivu-btn-error.active{background-color:#c93f36;border-color:#c93f36}
.ivu-btn-text{color:var(--brand)}
.ivu-btn-text:hover{color:var(--brand-dark)}
.ivu-tabs-ink-bar{background-color:var(--brand)}
.ivu-tabs-tab:hover,.ivu-tabs-tab-active{color:var(--brand)}
.base-wrap-main .ivu-page-item-active{background:var(--brand);border-color:var(--brand)}
.base-wrap-main .ivu-page-item-active a,.base-wrap-main .ivu-page-item-active a:hover{color:#fff !important}
.base-wrap-main .ivu-page-item:hover{border-color:var(--brand)}
.base-wrap-main .ivu-page-item:hover a,.base-wrap-main .ivu-page-item:hover i{color:var(--brand)}
.base-wrap-main .ivu-page-prev:hover i,.base-wrap-main .ivu-page-next:hover i{border-color:var(--brand);color:var(--brand)}
.ivu-select-item-selected{color:var(--brand)}
.ivu-select-item-selected:hover{background:var(--brand-soft)}
.ivu-checkbox-checked .ivu-checkbox-inner{border-color:var(--brand);background-color:var(--brand)}
.ivu-radio-checked .ivu-radio-inner{border-color:var(--brand);background-color:var(--brand)}
.ivu-checkbox-wrapper:hover .ivu-checkbox-inner,.ivu-radio-wrapper:hover .ivu-radio-inner{border-color:var(--brand)}
.ivu-input:focus,.ivu-input-focused,.ivu-select-visible .ivu-select-selection{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand-a)}
.ivu-input:hover,.ivu-select-selection:hover{border-color:#c6d0e2}
.ivu-switch-checked{border-color:var(--brand);background-color:var(--brand)}
.ivu-tree-title:hover{background-color:var(--brand-soft)}
.ivu-tree-title-selected,a.ivu-tree-title-selected:hover{background-color:var(--brand-soft);color:var(--brand)}
.ivu-spin-dot{background-color:var(--brand)}
.ivu-modal-header-inner{font-weight:600;color:var(--text)}
a{color:var(--brand)}
a:hover{color:var(--brand-dark)}
/* ---- 滚动条(定稿全局) ---- */
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-thumb{background:#c9d0dd;border-radius:8px}
::-webkit-scrollbar-thumb:hover{background:#aeb8c9}
::-webkit-scrollbar-track{background:transparent}
/* ---- 工作台/主题管理全屏页深色顶栏吃主题变量(方案 §7.3) ---- */
.wb-header{background:var(--topbar-bg) !important}
`;
writeFileSync(resolve(root, "src/assets/styles/yh-theme.css"), css, "utf8");
console.log("yh-theme.css written:", Buffer.byteLength(css), "bytes");
+178 -18
View File
@@ -1,11 +1,12 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* B5 栏目工作台组件行为(方案 P1-2): * B5 栏目工作台组件行为(方案 P1-2)+ P-D1 内容管理 IA 切分(v2 §B):
* ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递; * ① 列表型栏目 → 右侧「栏目内容概览」(统计 + 最近 5 条只读 + CTA),不再渲染完整操作表;
* ② 切换树节点 → 面板 watch categoryId 联动 reload; * ② 切换树节点 → 概览 watch categoryId 联动 reload;
* ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表; * ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表;
* ④ 外链型 → linkUrl + 打开链接; * ④ 外链型 → linkUrl + 打开链接;
* ⑤ 新建文章按钮 → 路由跳转带 categoryId query。 * ⑤ 新建文章按钮 → 路由跳转带 categoryId query;
* ⑥ T-D-02/03:/article 读 ?categoryId= 预筛选;概览"查看全部"深链到该地址。
* *
* 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。 * 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。
*/ */
@@ -14,18 +15,26 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus"; import ViewUIPlus from "view-ui-plus";
const { pageMock, routerPush, openMock, saveMock } = vi.hoisted(() => ({ const { pageMock, routerPush, openMock, saveMock, saveRejection, pageResult, treeFixture, routeMock } = vi.hoisted(() => ({
pageMock: vi.fn(), pageMock: vi.fn(),
routerPush: vi.fn(), routerPush: vi.fn(),
openMock: vi.fn(), openMock: vi.fn(),
saveMock: vi.fn(), saveMock: vi.fn(),
/** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */
saveRejection: { value: -1 },
/** articleApi.page 返回值桩:默认空列表(各用例按需覆盖 list/total) */
pageResult: { value: { list: [] as any[], total: 0 } },
/** categoryApi.tree 返回值桩:默认空树(用例按需给栏目层级) */
treeFixture: { value: [] as any[] },
/** useRoute 桩:可变 query,测 ?categoryId= 深链初始化 */
routeMock: { value: { params: {}, query: {} as Record<string, string> } },
})); }));
vi.mock("@/api/module/cms", () => ({ vi.mock("@/api/module/cms", () => ({
articleApi: { articleApi: {
page: (query: any) => { page: (query: any) => {
pageMock(query); pageMock(query);
return { send: () => Promise.resolve({ list: [], total: 0 }) }; return { send: () => Promise.resolve(pageResult.value) };
}, },
publish: () => ({ send: () => Promise.resolve({}) }), publish: () => ({ send: () => Promise.resolve({}) }),
offline: () => ({ send: () => Promise.resolve({}) }), offline: () => ({ send: () => Promise.resolve({}) }),
@@ -33,10 +42,16 @@ vi.mock("@/api/module/cms", () => ({
delete: () => ({ send: () => Promise.resolve({}) }), delete: () => ({ send: () => Promise.resolve({}) }),
}, },
categoryApi: { categoryApi: {
tree: () => ({ send: () => Promise.resolve([]) }), tree: () => ({ send: () => Promise.resolve(treeFixture.value) }),
save: (payload: any) => { save: (payload: any) => {
const nth = saveMock.mock.calls.length;
saveMock(payload); saveMock(payload);
return { send: () => Promise.resolve({}) }; return {
send: () =>
saveRejection.value === nth
? Promise.reject(new Error("栏目名称已存在"))
: Promise.resolve({ id: 100 + nth }),
};
}, },
delete: () => ({ send: () => Promise.resolve({}) }), delete: () => ({ send: () => Promise.resolve({}) }),
}, },
@@ -46,7 +61,7 @@ vi.mock("@/api/module/cms", () => ({
vi.mock("vue-router", () => ({ vi.mock("vue-router", () => ({
useRouter: () => ({ push: routerPush, replace: vi.fn() }), useRouter: () => ({ push: routerPush, replace: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }), useRoute: () => routeMock.value,
})); }));
/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */ /** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */
@@ -59,6 +74,8 @@ vi.mock("@/components/CategoryTreePanel.vue", () => ({
emit("update:curTreeNode", node); emit("update:curTreeNode", node);
emit("on-change", node); emit("on-change", node);
}; };
// P-D-01:概览统计要"直属+子孙",用 on-tree-data 把(含虚拟根的)栏目树喂给 CategoryManage.treeData
(globalThis as any).__treeEmitData = (list: any[]) => emit("on-tree-data", list);
(globalThis as any).__treeProps = props; (globalThis as any).__treeProps = props;
// CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API) // CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API)
expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} }); expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} });
@@ -79,6 +96,7 @@ vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
import ArticleListPanel from "@/components/article/ArticleListPanel.vue"; import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
import CategoryManage from "@/views/cms/CategoryManage.vue"; import CategoryManage from "@/views/cms/CategoryManage.vue";
import BatchAddCategory from "@/components/BatchAddCategory.vue";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
@@ -117,7 +135,11 @@ async function flush(frames = 5) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(globalThis as any).__treeEmit = undefined; (globalThis as any).__treeEmit = undefined;
(globalThis as any).__treeEmitData = undefined;
(globalThis as any).__treeProps = undefined; (globalThis as any).__treeProps = undefined;
pageResult.value = { list: [], total: 0 };
treeFixture.value = [];
routeMock.value = { params: {}, query: {} };
currentStore = null; currentStore = null;
window.open = openMock as any; window.open = openMock as any;
}); });
@@ -137,7 +159,7 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
await flush(); await flush();
expect(pageMock).toHaveBeenCalled(); expect(pageMock).toHaveBeenCalled();
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryId: 5 }); expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
// 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目") // 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目")
expect(el.textContent).not.toContain("全部栏目"); expect(el.textContent).not.toContain("全部栏目");
expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true); expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true);
@@ -156,11 +178,24 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
await flush(); await flush();
expect(pageMock).toHaveBeenCalledTimes(2); expect(pageMock).toHaveBeenCalledTimes(2);
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryId: 7 }); expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 });
}); });
it("⑤ 面板新建文章:路由跳转带 categoryId query", async () => { it("T-D-02 文章页 ?categoryId= 深链:读 query 初始化栏目筛选并回显下拉", async () => {
const state = reactive({ cid: 5, fixed: true }); treeFixture.value = [{ id: 5, name: "新闻中心", type: 1, children: [{ id: 51, name: "校园时讯", type: 1 }] }];
routeMock.value = { params: {}, query: { categoryId: "5" } };
const el = mountApp(defineComponent({
setup: () => () => h(ArticleListPanel, {}),
}));
await flush();
// query 里的字符串 id 须转成数字:既对齐后端 categoryIds,也让栏目下拉回显选中项
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
expect(el.querySelector(".ivu-select-selected-value")?.textContent).toContain("新闻中心");
});
it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => {
const state = reactive({ cid: 5, fixed: false });
const host = defineComponent({ const host = defineComponent({
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }), setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
}); });
@@ -172,7 +207,13 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush(); await flush();
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit?categoryId=5"); // 全局列表模式:新建不带栏目预选(栏目由用户在下拉自选)
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit");
// 固定栏目模式:新建入口由 CategoryManage 面板头承担,面板内不再渲染
state.fixed = true;
await flush();
expect([...el.querySelectorAll("button")].some(b => b.textContent?.includes("新建文章"))).toBe(false);
}); });
}); });
@@ -203,15 +244,75 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank"); expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank");
}); });
it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => { it("T-D-01 列表型栏目:右侧降级为「栏目内容概览」(统计 + 最近 5 条只读),不再渲染操作表", async () => {
pageResult.value = {
total: 12,
list: [1, 2, 3, 4, 5, 6].map(i => ({
id: 900 + i,
title: `云海大学第${i}次党代会筹备推进会`,
categoryId: 5,
categoryName: "新闻中心",
status: 1,
publishTime: "2026-09-20 10:00",
topmost: 0,
})),
};
const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
await flush();
// 统计口径 = 直属 + 子孙栏目:树经 on-tree-data 下发(含 W2 虚拟根),子孙 51/52 须并入计数
(globalThis as any).__treeEmitData([
{
id: 0,
name: "演示学校",
virtual: true,
children: [
{
id: 5,
name: "新闻中心",
type: 1,
children: [
{ id: 51, name: "校园时讯", type: 1 },
{ id: 52, name: "通知公告", type: 1 },
],
},
],
},
]);
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
await flush();
const box = el.querySelector(".category-content-summary");
expect(box).toBeTruthy();
// 完整的文章操作表不再出现在栏目页(唯一操作台是 /article)
expect(el.querySelector(".article-list-panel")).toBeNull();
expect(box!.querySelector(".ivu-table")).toBeNull();
// 概览内不得出现文章操作入口(发布/下架/置顶/删除按钮);状态说明文字不算
const affordances = [...box!.querySelectorAll("button, a")].map(n => (n.textContent || "").trim());
expect(affordances.some(t => ["发布", "下架", "取消置顶", "置顶", "删除"].includes(t))).toBe(false);
// 统计查询:直属+子孙(逗号分隔,后端 List<Integer> 契约)、全状态(不带 status)、只要最近 5 条
expect(pageMock).toHaveBeenCalled();
const q = pageMock.mock.calls[0][0];
expect(q).toMatchObject({ siteId: 1, categoryIds: "5,51,52", page: 1, pageSize: 5 });
expect(q.status).toBeUndefined();
expect(box!.textContent).toContain("共 12 篇");
// 只读 5 条(第 6 条不渲染)
expect(box!.querySelectorAll(".ccs-row")).toHaveLength(5);
expect(box!.textContent).toContain("云海大学第1次党代会筹备推进会");
});
it("T-D-03 概览「查看全部 →」跳 /article 深链(带 categoryId 预筛选)", async () => {
const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); const el = mountApp(defineComponent({ render: () => h(CategoryManage) }));
await flush(); await flush();
(globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 }); (globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 });
await flush(); await flush();
expect(el.querySelector(".article-list-panel")).toBeTruthy(); const more = [...el.querySelectorAll("a, button")].find(b => b.textContent?.includes("查看全部"));
expect(pageMock).toHaveBeenCalled(); expect(more).toBeTruthy();
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryId: 5 }); more!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article?categoryId=5");
}); });
it("未选中节点:右侧显示空态引导", async () => { it("未选中节点:右侧显示空态引导", async () => {
@@ -221,6 +322,65 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
}); });
}); });
describe("categoryWorkbench - BatchAddCategory(批量添加栏目)", () => {
/** 打开批量添加弹窗并填入多行名称(Modal transfer 到 body,按 .ivu-modal 定位) */
async function openBatchWith(names: string, props: any = {}) {
const onSuccess = vi.fn();
const host = defineComponent({
setup: () => () =>
h(BatchAddCategory, {
visible: true,
defaultPid: 5,
parentName: "新闻中心",
defaultType: 1,
onSuccess,
...props,
}),
});
const el = mountApp(host);
await flush();
const textarea = document.querySelector(".ivu-modal textarea") as HTMLTextAreaElement;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
setter.call(textarea, names);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
const submit = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("确认"));
submit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush(8);
return { onSuccess, el };
}
it("多行名称逐条循环调 save,按行序写 sortNo=1..n,成功后回调", async () => {
saveRejection.value = -1;
const { onSuccess } = await openBatchWith("通知公告\n规章制度\n三务公开");
expect(saveMock).toHaveBeenCalledTimes(3);
expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 5, name: "通知公告", type: 1, sortNo: 1 });
expect(saveMock.mock.calls[1][0]).toMatchObject({ name: "规章制度", sortNo: 2 });
expect(saveMock.mock.calls[2][0]).toMatchObject({ name: "三务公开", sortNo: 3 });
expect(onSuccess).toHaveBeenCalledWith(102);
});
it("空行自动忽略;某行失败不影响后续行,结束统一告警", async () => {
saveRejection.value = 1; // 第 2 条拒绝
const warnSpy = vi.spyOn(Message, "warning");
const { onSuccess } = await openBatchWith("通知公告\n\n规章制度\n三务公开");
expect(saveMock).toHaveBeenCalledTimes(3);
expect(warnSpy).toHaveBeenCalled();
expect(onSuccess).toHaveBeenCalled();
saveRejection.value = -1;
});
it("未输入名称时不发请求", async () => {
const callsBefore = saveMock.mock.calls.length;
await openBatchWith(" ");
expect(saveMock.mock.calls.length).toBe(callsBefore);
});
});
describe("W2 栏目树修订(T-W2-06..10)", () => { describe("W2 栏目树修订(T-W2-06..10)", () => {
const selectRoot = async () => { const selectRoot = async () => {
(globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] }); (globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] });
+351
View File
@@ -0,0 +1,351 @@
// @vitest-environment jsdom
/**
* 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
* T-B-09 quick_links/notice_bar 属性面板行为不变;
* F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
* 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
const treeMock = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock("@/api/module/cms", () => ({
categoryApi: { tree: () => treeMock },
}));
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
import { CategoryTreeNode } from "@/types/cms";
const cleanups: (() => void)[] = [];
/** 排空微任务 + 一个宏任务(view-ui-plus 的 Select 标签回填走 nextTick/setTimeout) */
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
for (let i = 0; i < frames; i++) {
await nextTick();
}
}
const CONFIG = JSON.stringify({
logo: "/a.png",
home_layout: {
version: 1,
blocks: [
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }] },
{ id: "n1", type: "notice_bar", text: "国庆放假安排", url: "/notice/gq", level: "warn" },
],
},
});
const TREE: CategoryTreeNode[] = [
{ id: 11, name: "校园时讯", type: 1, children: [] },
{ id: 12, name: "媒体聚焦", type: 1, children: [] },
] as unknown as CategoryTreeNode[];
async function mountEditor(
configJson: string,
opts?: { siteId?: number; portalUrl?: string; siteIdRef?: ReturnType<typeof ref<number | undefined>> },
) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(configJson);
const app = createApp(
defineComponent({
render: () =>
h(HomeLayoutEditor, {
modelValue: model.value,
siteId: opts?.siteIdRef ? opts.siteIdRef.value : opts?.siteId,
portalUrl: opts?.portalUrl,
"onUpdate:modelValue": (v: string) => (model.value = v),
}),
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle(6);
return { el, model };
}
/** 取属性面板中指定 label 的字段容器 */
function fieldOf(el: HTMLElement, label: string): HTMLElement | null {
return (
Array.from(el.querySelectorAll<HTMLElement>(".hle-field")).find(
f => f.querySelector(".hle-field-label")?.textContent?.trim() === label,
) || null
);
}
/** 第 index 行的删除按钮 */
function del(field: HTMLElement, index: number): HTMLElement {
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
}
beforeEach(() => {
treeMock.send.mockReset();
treeMock.send.mockResolvedValue([]);
});
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => {
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
const { el, model } = await mountEditor(CONFIG);
// 序列行摘要走 blockSummary 新增分支
expect(el.textContent).toContain("1 个入口");
expect(el.textContent).toContain("醒目提示");
// 选中 quick_links 行 → 属性面板出现入口列表编辑器
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "入口列表");
expect(field).not.toBeNull();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
expect(field!.querySelectorAll(".hle-link-row input").length).toBe(2);
const add = field!.querySelector(".hle-link-add") as HTMLElement;
expect(add.textContent).toContain("添加入口");
add.click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(2);
// 新增行是空行:回写布局时空行由 cleanBlock 剔除,故提交形态仍只 1 项
const blocks = JSON.parse(model.value).home_layout.blocks;
expect(blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
// 其余键无损
expect(JSON.parse(model.value).logo).toBe("/a.png");
// 删掉尾部的空行 → 回到 1 行,提交形态不变
del(field!, 1).click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(1);
expect(JSON.parse(model.value).home_layout.blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]);
// 再删唯一入口行 → 空列表(提交时 items 不落键,由后端校验拦下「至少 1 个入口」)
del(field!, 0).click();
await settle();
expect(field!.querySelectorAll(".hle-link-row").length).toBe(0);
expect(JSON.parse(model.value).home_layout.blocks[0].items).toBeUndefined();
});
it("quick_links:12 项上限时添加按钮禁用", async () => {
const items = Array.from({ length: 12 }, (_, i) => ({ name: `入口${i}`, url: `/e${i}` }));
const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "q1", type: "quick_links", items }] } });
const { el } = await mountEditor(config);
(el.querySelectorAll(".hle-row")[0] as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "入口列表")!;
expect(field.querySelectorAll(".hle-link-row").length).toBe(12);
expect((field.querySelector(".hle-link-add") as HTMLButtonElement).disabled).toBe(true);
});
it("notice_bar:正文输入放宽到 200 字、跳转可选、level 为 info/warn 选择器", async () => {
const { el } = await mountEditor(CONFIG);
(el.querySelectorAll(".hle-row")[1] as HTMLElement).click();
await settle();
const text = fieldOf(el as HTMLElement, "公告正文")!.querySelector("input")!;
expect(text.getAttribute("maxlength")).toBe("200");
expect(fieldOf(el as HTMLElement, "跳转地址")).not.toBeNull();
const levelField = fieldOf(el as HTMLElement, "提示级别")!;
expect(levelField.querySelector(".ivu-select")).not.toBeNull();
expect(levelField.textContent).toContain("醒目");
});
});
describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => {
it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => {
const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" });
expect(el.querySelector(".hle-sequence")).toBeTruthy();
expect(el.querySelector(".hle-props")).toBeTruthy();
const preview = el.querySelector(".hle-preview")!;
expect(preview).toBeTruthy();
expect(preview.querySelector("iframe")).toBeTruthy();
expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/");
// 视口三档
expect(preview.textContent).toContain("桌面 1200");
expect(preview.textContent).toContain("平板 768");
expect(preview.textContent).toContain("手机 390");
const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览"));
expect(refresh).toBeTruthy();
});
it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => {
const { el } = await mountEditor(CONFIG);
const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!;
addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle();
// Drawer 渲染到 body 级传送门
const drawer = document.querySelector(".hle-drawer") || document.body;
const cards = drawer.querySelectorAll(".hle-type-card");
expect(cards.length).toBeGreaterThanOrEqual(6);
cards.forEach(c => {
expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy();
});
});
it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
});
const { el, model } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const styleField = fieldOf(el as HTMLElement, "展示形态")!;
expect(styleField.querySelector(".hle-style-cards")).toBeTruthy();
expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5);
expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5);
// 点击第 2 张卡(news_grid)→ 选中态 + 回写布局
(styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click();
await settle();
expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active");
expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid");
});
it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => {
treeMock.send.mockResolvedValue(TREE);
const noLayoutConfig = JSON.stringify({ logo: "/a.png" });
const { model } = await mountEditor(noLayoutConfig, { siteId: 1 });
const layout = JSON.parse(model.value).home_layout;
expect(layout).toBeTruthy();
expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery
expect(layout.blocks[0].type).toBe("ticker");
expect(layout.blocks.some((b: { type: string; title?: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true);
expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true);
// 其余键无损
expect(JSON.parse(model.value).logo).toBe("/a.png");
});
it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => {
treeMock.send.mockResolvedValue(TREE);
const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } });
const { model } = await mountEditor(emptyLayout, { siteId: 1 });
expect(JSON.parse(model.value).home_layout.blocks).toEqual([]);
});
it("无列表栏目不触发自动编排", async () => {
treeMock.send.mockResolvedValue([]);
const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 });
expect(JSON.parse(model.value).home_layout).toBeUndefined();
});
});
describe("HomeLayoutEditor - 栏目树加载与首用编排解耦(docs/20 返工 #1)", () => {
it("已有布局 + 有列表栏目 + siteId:栏目树仍无条件加载,数据栏目可勾选", async () => {
treeMock.send.mockResolvedValue(TREE);
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
});
const { el, model } = await mountEditor(config, { siteId: 1 });
// 树加载发生(幂等标记下 treeMock 恰好 1 次调用)
expect(treeMock.send).toHaveBeenCalledTimes(1);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
expect(cats).not.toBeNull();
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
expect(cats.textContent).toContain("校园时讯");
expect(cats.textContent).toContain("媒体聚焦");
// 已有布局不被自动编排改写
expect(JSON.parse(model.value).home_layout.blocks[0].id).toBe("a1");
});
it("siteId 晚于挂载到位(父级异步种入):watch 补跑加载,数据栏目可用", async () => {
treeMock.send.mockResolvedValue(TREE);
const siteIdRef = ref<number | undefined>(undefined);
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [], style: "card", size: 8 }] },
});
const { el } = await mountEditor(config, { siteIdRef });
await settle();
// siteId 未到位时不请求
expect(treeMock.send).not.toHaveBeenCalled();
siteIdRef.value = 1;
await settle(6);
expect(treeMock.send).toHaveBeenCalledTimes(1);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
});
it("watch + onMounted 双触发下幂等:同 siteId 只请求一次", async () => {
treeMock.send.mockResolvedValue(TREE);
const siteIdRef = ref<number | undefined>(1);
await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteIdRef });
await settle(6);
expect(treeMock.send).toHaveBeenCalledTimes(1);
});
});
describe("HomeLayoutEditor - 区块序列序号徽标(docs/20 返工 #3)", () => {
it("每行右上角显示连续序号(1 起始、含隐藏区块不重排),与校验报错「第 N 个区块」对齐", async () => {
const config = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 },
{ id: "n1", type: "notice_bar", text: "公告", visible: false },
{ id: "g1", type: "gallery" },
],
},
});
const { el } = await mountEditor(config);
const nos = Array.from(el.querySelectorAll(".hle-row-no")).map(n => n.textContent!.trim());
expect(nos).toEqual(["1", "2", "3"]);
// 隐藏区块(第 2 行)保留序号、行整体置灰,编号不重排
const rows = Array.from(el.querySelectorAll(".hle-row"));
expect(rows[1].className).toContain("hidden");
expect(rows[1].querySelector(".hle-row-no")!.textContent!.trim()).toBe("2");
});
});
describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => {
it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [
{ id: "s1", type: "stats_band", stats: [{ label: "在校生", value: "3.2", unit: "万" }] },
] },
});
const { el } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "统计项")!;
expect(field.querySelector(".hle-stat-row")).toBeTruthy();
expect((field.querySelector(".hle-stat-row input") as HTMLInputElement)).toBeTruthy();
const add = field.querySelector(".hle-stat-add, .hle-link-add") as HTMLElement;
add.click();
await settle();
expect(field.querySelectorAll(".hle-stat-row").length).toBe(2);
});
it("gallery:布局形态下拉含瀑布流(campus_wall)选项且选中回写", async () => {
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "g1", type: "gallery" }] },
});
const { el, model } = await mountEditor(config);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const field = fieldOf(el as HTMLElement, "布局形态")!;
const select = field.querySelector(".ivu-select") as HTMLElement;
expect(select).toBeTruthy();
// 展开下拉点选 masonry
select.querySelector(".ivu-select-selection").dispatchEvent(new MouseEvent("click", { bubbles: true }));
await settle(8);
const opt = [...document.querySelectorAll(".ivu-select-item")].find(n => n.textContent?.includes("瀑布流")) as HTMLElement;
expect(opt).toBeTruthy();
opt.click();
await settle(8);
expect(JSON.parse(model.value).home_layout.blocks[0].layout).toBe("masonry");
});
});
+114
View File
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
/**
* SiteConfigForm 组件级回归(docs/20 返工 #4):
* - 本分区无必填字段:全空 → validate() 通过(返回 null),不因「必填」拦保存;
* - 全合法值 → validate() 通过;
* - 填了但格式非法 → validate() 返回该字段的明确错误(「字段名:原因」),
* 且表单内出现 iView 内联红框(.ivu-form-item-error),不再有「红星必填」式误导。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 homeLayoutEditor.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
const cleanups: (() => void)[] = [];
/** 排空微任务 + 一个宏任务(iView 校验态/DOM 类名更新走 nextTick) */
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
for (let i = 0; i < frames; i++) {
await nextTick();
}
}
async function mountForm(modelValue: string, groups: string[]) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(modelValue);
const childRef = ref<any>(null);
const app = createApp(
defineComponent({
setup() {
return () =>
h(SiteConfigForm, {
ref: childRef,
modelValue: model.value,
groups,
"onUpdate:modelValue": (v: string) => (model.value = v),
});
},
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle(6);
return { el, model, child: childRef };
}
/** 模拟真实输入:走原生 input 事件(iView Input 据此回填 v-model 并触发 on-change 回写) */
async function typeByPlaceholder(el: HTMLElement, placeholder: string, value: string) {
const input = Array.from(el.querySelectorAll<HTMLInputElement>("input, textarea")).find(
n => n.getAttribute("placeholder") === placeholder,
);
expect(input, `placeholder ${placeholder} 对应的输入框应存在`).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input!, value);
input!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
}
beforeEach(() => {});
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("SiteConfigForm 校验(docs/20 返工 #4)", () => {
it("全空(无必填字段)→ validate() 通过返回 null", async () => {
const { child } = await mountForm("", ["display"]);
const err = await child.value.validate();
expect(err).toBeNull();
});
it("全合法值 → validate() 通过返回 null", async () => {
const { el, child } = await mountForm("", ["display"]);
await typeByPlaceholder(el, "门户顶栏展示的一句话标语(可选)", "云海大学欢迎您");
await typeByPlaceholder(el, "留空默认「专业院系」", "院系导航");
const err = await child.value.validate();
expect(err).toBeNull();
});
it("合法 configJson 种入 + 手填非法 URL → 返回「字段名:原因」,出现内联红框", async () => {
const { el, child } = await mountForm(JSON.stringify({ logo: "/yschcms/upload/2026/06/logo.png" }), ["footer"]);
await typeByPlaceholder(el, "微博主页链接 https://weibo.com/…(留空不展示图标)", "不是链接");
const err = await child.value.validate();
expect(typeof err).toBe("string");
expect(err).toContain("微博");
expect(err).toContain("http");
// 内联红框 + 字段下方错误文案存在
expect(el.querySelector(".ivu-form-item-error .ivu-form-item-error-tip")).toBeTruthy();
});
it("asset 字段非法值同样精确报错(种入非法 logo 值)", async () => {
// REWORK-DECISION: 经控件 UI 填 asset 值的路径依赖上传(jsdom 不可行)与「手动粘贴」通道
// (其「填入」按钮因 slot="append" 旧语法在 Vue 3 下不渲染,属本次返工范围外的存量缺陷,
// 见返工报告遗留项),故从种入 configJson 驱动:等价覆盖「存在非法 asset 值 → 精确报错」。
const { el, child } = await mountForm(JSON.stringify({ logo: "随便写的路径" }), ["display"]);
const err = await child.value.validate();
expect(typeof err).toBe("string");
expect(err).toContain("站点 Logo");
expect(err).toContain("上传路径");
expect(el.querySelector(".ivu-form-item-error")).toBeTruthy();
});
});
+353
View File
@@ -0,0 +1,353 @@
// @vitest-environment jsdom
/**
* docs/13 方案 C 站点分层(URL 站点段 + 站点身份条):
* ① 深链子站 URL → store.currentSite 同步为子站并可感知(徽章/色带);
* ② 非法/跨校/主站编码当子站段 → 告警并打回同页主站形态;
* ③ URL 无站点段即主站(localStorage 记忆不再作导航决策);
* ④ 身份条下拉一步切换:只改 URL、停留当前功能页、点后收起;
* ⑤ 子站形态菜单高亮剥段匹配(菜单 page 保持主站形态)。
*
* 沿用手写 createApp 挂载 + vi.mock(vue-router) 桩风格(与 categoryWorkbench.spec.ts 一致);
* route 用 reactive 包装以驱动 SchoolLayout 的 watch(fullPath)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createPinia, setActivePinia } from "pinia";
import ViewUIPlus, { Message } from "view-ui-plus";
const box = vi.hoisted(() => ({
route: null as any,
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
}));
vi.mock("vue-router", () => ({
useRoute: () => box.route,
useRouter: () => ({ push: box.push, replace: box.replace, go: box.go, getRoutes: () => [] }),
}));
import SchoolLayout from "@/components/SchoolLayout.vue";
import { useCmsStore } from "@/store";
/** 站点卡皮肤包名取 user.sites[].theme(后端 AuthUserVo.sites = List<SiteVo>,字段已具备,零后端改动) */
const SITES = [
{ id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, theme: "scholar-blue", schId: 1001, schName: "云海大学" },
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, theme: "campus-glow", schId: 1001, schName: "云海大学" },
{ id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
];
let cleanups: (() => void)[] = [];
let currentStore: ReturnType<typeof useCmsStore> | null = null;
function setRoute(path: string, params: Record<string, string>, meta: any = { title: "文章列表", modId: 11 }) {
box.route = reactive({ path, fullPath: path, params, query: {}, hash: "", meta });
}
/** 挂载学校壳;restoredSite 模拟刷新首帧从 localStorage 恢复的站点残留 */
function mountLayout(restoredSite?: any) {
const pinia = createPinia();
const store = useCmsStore(pinia);
currentStore = store;
store.user = { superAdmin: true, sites: SITES } as any;
store.currentSchool = {
schId: 1001,
schName: "云海大学",
mainSiteId: 1,
mainSiteCode: "demo",
mainSiteName: "云海大学门户",
} as any;
store.currentSite = (restoredSite || { id: 1, code: "demo", name: "云海大学门户" }) as any;
store.session.modList = [{ id: 11, name: "文章列表", modId: 11, page: "/article" }];
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp(defineComponent({ render: () => h(SchoolLayout) }));
app.use(pinia);
app.use(ViewUIPlus);
app.component("router-view", defineComponent({ render: () => h("div", { class: "rv-stub" }) }));
// BreadcrumbItem 传 to 时经 mixins-link 解析链接,需要 $router 桩(测试不引入真实路由实例)
app.config.globalProperties.$router = {
resolve: (to: any) => ({ href: "#" + (typeof to === "string" ? to : to?.path || "") }),
push: box.push,
replace: box.replace,
};
app.config.globalProperties.$route = box.route;
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
return el;
}
async function flush(frames = 5) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
currentStore = null;
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("docs/13 站点分层:URL 站点段 ↔ 上下文同步", () => {
it("① 深链子站 URL:currentSite 同步为子站,徽章「院系子站」+ 橙色带 + 面包屑站点段", async () => {
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
const el = mountLayout();
await flush();
expect(currentStore!.currentSite!.id).toBe(2);
expect(currentStore!.currentSite!.code).toBe("jdxy");
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("院系子站");
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
// 面包屑:学校列表 > 云海大学 > [院系子站] 机电学院 > 文章列表
expect(el.textContent).toContain("云海大学");
expect(el.textContent).toContain("机电学院");
expect(el.textContent).toContain("文章列表");
});
it("② 非法/跨校 siteCode:告警并打回同页主站形态,上下文归主站", async () => {
const warn = vi.spyOn(Message, "warning");
setRoute("/sch/demo/site/nope/category", { schCode: "demo", siteCode: "nope" }, { title: "栏目管理", modId: 2 });
mountLayout();
await flush();
expect(warn).toHaveBeenCalled();
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/category" }));
expect(currentStore!.currentSite!.id).toBe(1);
});
it("②b 把主站编码当子站段(/site/demo/)同样视为非法并打回", async () => {
setRoute("/sch/demo/site/demo/article", { schCode: "demo", siteCode: "demo" });
mountLayout();
await flush();
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/article" }));
});
it("③ URL 无站点段即主站:localStorage 里残留的子站上下文不再恢复", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout({ id: 2, code: "jdxy", name: "机电学院" });
await flush();
expect(currentStore!.currentSite!.id).toBe(1);
expect(el.querySelector(".path-nav")!.className).toContain("band-main");
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("校主站");
});
it("②c 切换 URL 到子站:同步跟随(同一壳内导航,五联动中的 store/徽章/色带)", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout();
await flush();
expect(currentStore!.currentSite!.id).toBe(1);
const p = "/sch/demo/site/wtxy/article";
box.route.path = p;
box.route.fullPath = p;
box.route.params = { schCode: "demo", siteCode: "wtxy" };
await flush();
expect(currentStore!.currentSite!.id).toBe(3);
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
expect(el.textContent).toContain("文学院");
});
});
describe("docs/13 站点身份条:SiteSwitcher 一步切换", () => {
it("④ 展开下拉 → 点击子站行 → push 当前功能页的子站形态 URL 并收起", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout();
await flush();
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
const dd = el.querySelector(".site-switcher-dd");
expect(dd).toBeTruthy();
// 分组结构:主站 1 行 + 院系子站 2 行
expect(el.querySelectorAll(".site-switcher-dd .dd-item")).toHaveLength(3);
expect(dd!.textContent).toContain("院系子站(2)");
const jdxy = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
r.textContent?.includes("机电学院")
)!;
jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
// 停留当前功能页(article),只改 URL;store 由路由同步接管
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article");
expect(el.querySelector(".site-switcher-dd")).toBeNull();
});
it("④b 子站态下切回主站:URL 省略站点段;Escape 收起面板", async () => {
setRoute("/sch/demo/site/jdxy/category", { schCode: "demo", siteCode: "jdxy" }, { title: "栏目管理", modId: 2 });
const el = mountLayout();
await flush();
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
const mainRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
r.textContent?.includes("云海大学门户")
)!;
mainRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(box.push).toHaveBeenCalledWith("/sch/demo/category");
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(el.querySelector(".site-switcher-dd")).toBeTruthy();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await flush();
expect(el.querySelector(".site-switcher-dd")).toBeNull();
});
});
describe("docs/13 菜单高亮:剥段匹配", () => {
it("⑤ 子站形态下菜单高亮命中;列表子页面面包屑链接带站点段(不落回主站)", async () => {
setRoute(
"/sch/demo/site/jdxy/article/edit",
{ schCode: "demo", siteCode: "jdxy" },
{ title: "新建文章", modId: 11, subOf: "/article" }
);
const el = mountLayout();
await flush();
const li = [...el.querySelectorAll(".left-menu li")].find(x => x.textContent?.includes("文章列表"));
expect(li).toBeTruthy();
expect(li!.className).toContain("active");
// 面包屑「文章列表」为可点段(非末段),链接须带 /site/jdxy 以停留子站
const crumb = [...el.querySelectorAll(".path-nav a")].find(a => a.textContent?.includes("文章列表"));
expect(crumb).toBeTruthy();
expect(crumb!.getAttribute("href")).toContain("/sch/demo/site/jdxy/article");
});
});
describe("docs/13 站点上下文持久化:刷新首帧兜底(docs/15 T3)", () => {
it("⑥ setCurrentSite 写入 localStorage;新 pinia restoreSite 恢复同值", () => {
const pinia1 = createPinia();
setActivePinia(pinia1);
const s1 = useCmsStore(pinia1);
s1.setCurrentSite({ id: 2, code: "jdxy", path: "demo/jdxy", name: "机电学院" } as any);
expect(JSON.parse(localStorage.getItem("cms.currentSite")!).code).toBe("jdxy");
const pinia2 = createPinia();
setActivePinia(pinia2);
const s2 = useCmsStore(pinia2);
s2.restoreSite();
expect(s2.currentSite?.id).toBe(2);
expect(s2.currentSite?.code).toBe("jdxy");
});
it("⑥b setCurrentSite(null) 清除持久化;无残留时 restoreSite 归 null", () => {
const pinia1 = createPinia();
setActivePinia(pinia1);
const s1 = useCmsStore(pinia1);
s1.setCurrentSite({ id: 2, code: "jdxy", name: "机电学院" } as any);
s1.setCurrentSite(null);
expect(localStorage.getItem("cms.currentSite")).toBeNull();
const pinia2 = createPinia();
setActivePinia(pinia2);
const s2 = useCmsStore(pinia2);
s2.restoreSite();
expect(s2.currentSite).toBeNull();
});
});
describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
it("⑦ 主站态「前往站点管理」:URL 不带段", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout();
await flush();
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(box.push).toHaveBeenCalledWith("/sch/demo/site");
});
it("⑦b 子站态「前往站点管理」保持站点段;点当前站点行不重复 push(幂等)", async () => {
setRoute("/sch/demo/site/jdxy/banner", { schCode: "demo", siteCode: "jdxy" }, { title: "轮播管理", modId: 3 });
const el = mountLayout();
await flush();
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
// 幂等:点当前子站行,目标 URL === 当前 URL,不产生 push
const jdxyRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
r.textContent?.includes("机电学院")
)!;
jdxyRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(box.push).not.toHaveBeenCalled();
// 再展开 → 底部入口带段保持当前站点上下文
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site");
});
});
/* ===== docs/22 v3:页头身份「学校名.站点名」 + 侧栏站点卡撤除 =====
口径:页头 .school-cap(> .sc-sep + .sc-site)是唯一常驻身份点,主站不写站全名统一「主站」;
切站入口收敛到面包屑站点身份条(T-B-* / SiteSwitcher 用例)与站点工作台卡片两处。
原 v2 的卡内切站用例(T-C-01b)随卡撤除而删除。 */
describe("P-C1 页头身份与侧栏卡撤除(docs/22 v3)", () => {
it("T-C-01 主站态:侧栏 .menu-top-box 复归空档(卡已撤),页头 = 学校名.主站", async () => {
setRoute("/sch/demo/article", { schCode: "demo" });
const el = mountLayout();
await flush();
// 站点卡撤除:侧栏顶部空档恢复为空 div(回到加卡之前的 layout.css 形态)
const topBox = el.querySelector(".left-menu .menu-top-box");
expect(topBox).toBeTruthy();
expect(topBox!.children).toHaveLength(0);
// 页头身份:学校名.站点名 —— 主站档位统一「主站」,不写站全名
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.主站");
expect(el.querySelector(".header-container .school-cap")!.textContent).toContain("云海大学");
expect(el.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
});
it("T-C-02 身份染色:子站态 .school-cap 挂 band-sub、.sc-site=站名;主站态 band-main、.sc-site=主站", async () => {
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
const sub = mountLayout();
await flush();
// 侧栏/面包屑色带档位保留(identityClass 仍在 .left-menu / .path-nav 上工作)
expect(sub.querySelector(".left-menu")!.className).toContain("band-sub");
expect(sub.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(true);
expect(sub.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("机电学院");
setRoute("/sch/demo/article", { schCode: "demo" });
const main = mountLayout();
await flush();
expect(main.querySelector(".left-menu")!.className).toContain("band-main");
expect(main.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(false);
expect(main.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站");
});
it("T-C-03 标题带站点:页头与 document.title 均为「页面名 · 学校名.站点名」,切站后跟随", async () => {
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
const el = mountLayout();
await flush();
expect(document.title).toBe("文章列表 · 云海大学.机电学院");
expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.机电学院");
// 同一壳内切回主站形态:标题与 document.title 同步为「学校名.主站」
box.route.path = "/sch/demo/article";
box.route.fullPath = "/sch/demo/article";
box.route.params = { schCode: "demo" };
await flush();
expect(document.title).toBe("文章列表 · 云海大学.主站");
});
});
+209
View File
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
/**
* 站点管理页回归:docs/22 站点工作台(默认卡片视图)+ 表格视图保留。
* 行「站点设置」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus, { Tag } from "view-ui-plus";
// SiteManage 的 columns 渲染函数裸用 Tag(vite dev 由 unplugin-auto-import 注入,
// vitest 管线不走该插件)——这里挂到全局作用域供其解析
(globalThis as any).Tag = Tag;
const { siteListMock, themeListMock, routerPushMock } = vi.hoisted(() => ({
siteListMock: vi.fn(),
themeListMock: vi.fn(),
routerPushMock: vi.fn(),
}));
const siteRow = {
id: 1,
code: "demo",
path: "demo",
parentSiteId: 0,
schId: 1001,
schName: "云海大学",
name: "云海大学门户网站",
theme: "default",
configJson: '{"logo_text":"云海大学"}',
status: 1,
};
const themeRow = {
name: "default",
title: "高校蓝(默认)",
description: "",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [],
source: "builtin",
inUseCount: 1,
editable: true,
usedBySiteNames: ["云海大学门户网站"],
};
vi.mock("@/api/module/cms", () => ({
siteApi: { list: () => ({ send: siteListMock }) },
themeApi: { list: () => ({ send: themeListMock }) },
assetUrl: (u: string) => u,
}));
vi.mock("vue-router", () => ({
useRouter: () => ({ push: routerPushMock, replace: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
}));
import SiteManage from "@/views/cms/SiteManage.vue";
import { useCmsStore } from "@/store";
let cleanups: (() => void)[] = [];
async function flush(frames = 6) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
function mountPage(storeSetup?: (store: any) => void) {
const el = document.createElement("div");
document.body.appendChild(el);
const pinia = createPinia();
if (storeSetup) {
storeSetup(useCmsStore(pinia));
}
const app = createApp(defineComponent({ render: () => h(SiteManage) }));
app.use(pinia);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
/** 操作列图标按钮(iView 固定列克隆两份;按行序取第 rowIndex+1 个命中) */
function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) {
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][rowIndex];
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
return btn;
}
/** docs/22 后默认视图为卡片;表格行操作用例先切到表格视图 */
async function switchToTable(el: ParentNode) {
const btn = [...el.querySelectorAll(".seg button")].find(b => b.textContent?.includes("表格"));
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
}
beforeEach(() => {
vi.clearAllMocks();
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
themeListMock.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("SiteManage 列表页(docs/16 整页化后)", () => {
it("① 行「站点设置」跳站点设置中心(主站形态无站点段)", async () => {
const el = mountPage();
await flush();
await switchToTable(el);
const btn = clickRowAction(el, "icon-edit");
expect(btn).toBeTruthy();
await flush();
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
});
it("② 子站行「站点设置」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
siteRow,
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
])));
const el = mountPage();
await flush();
await switchToTable(el);
clickRowAction(el, "icon-edit", 1);
await flush();
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/jdxy/site/setting/2");
});
it("③「新建站点」按钮跳创建模式(site/new;超管无学校上下文回落首主站编码)", async () => {
const el = mountPage(store => {
store.user = { superAdmin: true } as any;
});
await flush();
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建站点"));
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
});
});
describe("站点工作台(docs/22 v3)", () => {
/** 1 主站 + 2 子站:卡片工作台的最小分组样本 */
function mockOneMainTwoSubs() {
siteListMock.mockResolvedValue(
JSON.parse(
JSON.stringify([
siteRow,
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机电学院" },
{ ...siteRow, id: 3, code: "wtxy", path: "demo/wtxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "文学院" },
])
)
);
}
it("T-SM-01 默认卡片视图:主站宽卡 1 + 子站网格 2,当前站唯一高亮;表格视图可切回", async () => {
mockOneMainTwoSubs();
const el = mountPage(store => {
store.user = { superAdmin: true } as any;
store.currentSite = { id: 2, code: "jdxy", name: "机电学院" } as any;
});
await flush();
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
expect(el.querySelectorAll(".site-card")).toHaveLength(2);
expect(el.querySelectorAll(".site-card.is-cur")).toHaveLength(1);
// 主站卡「子站」统计 = 过滤前真实规模(SiteVo 无栏目/文章字段,仅子站数必显)
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
// 切表格视图:卡片网格消失、ivu-table 出现
await switchToTable(el);
expect(el.querySelector(".card-grid")).toBeNull();
expect(el.querySelector(".ivu-table")).toBeTruthy();
});
it("T-SM-02 搜索过滤:命中子站保留、主站宽卡不消失(层级锚点),子站统计不随过滤变化", async () => {
mockOneMainTwoSubs();
const el = mountPage(store => {
store.user = { superAdmin: true } as any;
store.currentSite = { id: 1, code: "demo", name: "云海大学门户网站" } as any;
});
await flush();
const input = el.querySelector(".seg-search input") as HTMLInputElement;
input.value = "机电";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(el.querySelectorAll(".main-card")).toHaveLength(1);
expect(el.querySelectorAll(".site-card")).toHaveLength(1);
expect(el.querySelector(".site-card")!.textContent).toContain("机电学院");
// rawSubs 按过滤前全量行统计:仍是 2 个子站
expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2");
});
});
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
/**
* docs/13 方案 C 复检:真实 vue-router 集成(docs/15 §3 T1/T4)。
* siteLayer.spec.ts 全程 router 桩(vi.mock vue-router),覆盖不到镜像层真实匹配与守卫重定向,
* 本文件装载 routes.ts 全量路由 + router/index.ts 守卫补位:
* ① 路由结构:/site/{code} 无 component 镜像层存在、镜像去 name、meta.modId 保留、空段 redirect 兜底;
* ② 镜像层真实 resolve 命中(渲染层由 router-view 对无 component 记录跳过推进,此处验匹配链);
* ③ 守卫:子站编码当学校段 → 定向主站前缀且余部原样保留(含站点段),并恢复学校上下文;
* ④ legacyRedirect 旧链带 id 重定向。
*/
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useCmsStore } from "@/store";
import router from "@/router";
import { routes } from "@/router/routes";
const SITES = [
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0, schId: 1001, schName: "云海大学" },
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1, schId: 1001, schName: "云海大学" },
];
let store: ReturnType<typeof useCmsStore>;
beforeEach(() => {
localStorage.clear();
const pinia = createPinia();
setActivePinia(pinia);
store = useCmsStore(pinia);
store.user = { superAdmin: true, sites: SITES } as any;
});
describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => {
it("① /site/{code} 无 component 镜像层存在;镜像去 name、meta.modId 保留、空段 redirect 到 article", () => {
const sch = routes.find(r => r.path === "/sch/:schCode")!;
const siteLayer = sch.children!.find(r => r.path === "site/:siteCode") as any;
expect(siteLayer).toBeTruthy();
expect(siteLayer.component).toBeUndefined();
expect(siteLayer.children![0].path).toBe("");
expect(siteLayer.children![0].redirect).toBeTruthy();
const mirror = siteLayer.children!.find((c: any) => c.path === "category");
expect(mirror!.name).toBeUndefined();
expect((mirror!.meta as any).modId).toBe(2);
});
it("①b 主站清单与镜像层不重名(防重复注册告警)", () => {
expect(router.getRoutes().filter(r => r.name === "CategoryManage")).toHaveLength(1);
expect(router.getRoutes().filter(r => r.name === "ArticleList")).toHaveLength(1);
});
it("② 子站 URL 真实 resolve 命中镜像记录;主站 URL 命中主记录", () => {
const sub = router.resolve("/sch/demo/site/jdxy/category");
expect(sub.matched).toHaveLength(3);
expect(sub.name).toBeUndefined();
expect(sub.meta.modId).toBe(2);
const main = router.resolve("/sch/demo/category");
expect(main.matched).toHaveLength(2);
expect(main.name).toBe("CategoryManage");
});
it("②c 事项库路由已随 V2.0 拆分移除(svc/item、svc/dict 不再注册,直达落入 404 重定向)", () => {
// 事项管理能力已迁独立办事大厅(svc-hall 应用);CMS 前端仅保留展示壳,
// 旧菜单路径由 UserBase 停用 + 通配重定向兜底。
const item = router.resolve("/sch/demo/svc/item");
expect(item.name).toBeUndefined();
});
});
describe("docs/13 守卫与旧链兼容(真实 router.push,docs/15 T4)", () => {
it("③ 子站编码当学校段 → 重定向主站前缀(余部保留)并恢复学校上下文", async () => {
await router.push("/sch/jdxy/article");
expect(router.currentRoute.value.path).toBe("/sch/demo/article");
expect(store.currentSchool?.mainSiteCode).toBe("demo");
});
it("③b 子站编码当学校段且 URL 已带站点段 → 站点段原样保留", async () => {
await router.push("/sch/jdxy/site/jdxy/category");
expect(router.currentRoute.value.path).toBe("/sch/demo/site/jdxy/category");
expect(router.currentRoute.value.params.siteCode).toBe("jdxy");
});
it("③c 学校编码不在用户可管理站点内 → 回工作台", async () => {
await router.push("/sch/other-school/article");
expect(router.currentRoute.value.path).toBe("/workbench");
});
it("④ 旧链 /article/edit/:id → 当前学校上下文主站形态(id 后缀保留)", async () => {
store.currentSchool = {
schId: 1001,
schName: "云海大学",
mainSiteId: 1,
mainSiteCode: "demo",
mainSiteName: "云海大学门户",
} as any;
await router.push("/article/edit/9");
expect(router.currentRoute.value.path).toBe("/sch/demo/article/edit/9");
});
});
+330
View File
@@ -0,0 +1,330 @@
// @vitest-environment jsdom
/**
* 站点设置中心(docs/16 主线A → docs/20 F1 Tab 化 + 渐进式向导)挂载回归:
* ① 创建模式(渐进式向导):无任何「创建站点后开放」灰态;主按钮=「创建站点,下一步」;
* 创建成功自动进入第二步(皮肤 Tab + Steps + 「保存并完成」);
* 校级锁定本校主站时门户地址预览随编码联动;
* ② 编辑模式:7 Tab 结构、基本信息回填、未注册皮肤告警保留原值;
* ③ Tab 独立保存:保存基本信息 = 以服务端事实源 configJson 提交(其他 Tab 键不丢、不连带提交);
* ④ 自定义样式独立 Tab(textarea 代码编辑器,非折叠面板)。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
const routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
const mocks = vi.hoisted(() => ({
siteList: vi.fn(),
siteSave: vi.fn(),
themeList: vi.fn(),
}));
const siteRow = {
id: 1,
code: "demo",
path: "demo",
parentSiteId: 0,
schId: 1001,
schName: "云海大学",
name: "云海大学门户网站",
theme: "default",
configJson: '{"logo":"/cms/upload/logo.png","gallery_title":"线上展厅","home_layout":{"version":1,"blocks":[{"id":"b1","type":"links"}]}}',
status: 1,
};
const themeRow = {
name: "default",
title: "高校蓝(默认)",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
};
vi.mock("@/api/module/cms", () => ({
siteApi: {
list: () => ({ send: mocks.siteList }),
save: (data: any) => ({ send: () => mocks.siteSave(data) }),
},
themeApi: { list: () => ({ send: mocks.themeList }) },
assetUrl: (u: string) => u,
uploadUrl: () => "/api/attachment/upload",
}));
vi.mock("vue-router", () => ({
useRouter: () => routerMock,
useRoute: () => routeMock,
}));
vi.mock("@/components/theme/ThemePicker.vue", () => ({
default: defineComponent({ props: ["themes", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-picker-stub") }),
}));
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({ props: ["theme", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-param-stub") }),
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({
props: ["modelValue", "groups", "siteId"],
// 返工 #4 起 validate 契约:null=通过;失败返回首个错误文案
methods: { validate: async () => null, ownFields: () => ({}) },
render: () => h("div", "config-form-stub"),
}),
}));
vi.mock("@/views/cms/homeLayout/HomeLayoutEditor.vue", () => ({
default: defineComponent({
props: ["modelValue", "siteId"],
emits: ["update:modelValue", "save"],
methods: { validate: () => null },
render: () => h("div", "layout-editor-stub"),
}),
}));
import SiteSetting from "@/views/cms/SiteSetting.vue";
import { useCmsStore } from "@/store";
let cleanups: (() => void)[] = [];
async function flush(frames = 6) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
function mountPage(storeSetup?: (store: any) => void) {
const el = document.createElement("div");
document.body.appendChild(el);
const pinia = createPinia();
if (storeSetup) {
// 挂载前把上下文注入同一 pinia(defineStore 幂等,组件内拿到同一实例)
storeSetup(useCmsStore(pinia));
}
const app = createApp(defineComponent({ render: () => h(SiteSetting) }));
app.use(pinia);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
beforeEach(() => {
vi.clearAllMocks();
routeMock.params = {};
routeMock.query = {};
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
mocks.themeList.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
mocks.siteSave.mockImplementation(async (data: any) => ({ ...siteRow, ...data, id: data.id ?? 99 }));
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("SiteSetting 创建模式(docs/20 F1 渐进式向导)", () => {
it("第一步仅基本信息表单:无任何灰态锁定提示,主按钮=创建站点,下一步", async () => {
const el = mountPage();
await flush();
expect(el.textContent).not.toContain("创建站点后开放");
expect(el.textContent).toContain("新建站点");
expect(el.textContent).toContain("创建站点,下一步");
expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')).toBeTruthy();
});
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
const el = mountPage();
await flush();
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
expect(input).toBeTruthy();
setInputValue(input, "jdxy");
await flush();
expect(el.textContent).toContain("/portal/jdxy/");
});
it("校级建子站:上级锁定本校主站,预览=上级地址/编码(原向导 lockParent 语义)", async () => {
const el = mountPage(store => {
store.user = { superAdmin: false } as any;
store.enterSchool(
{ schId: 1001, schName: "云海大学", mainSiteId: 1, mainSiteCode: "demo", mainSiteName: "云海大学门户网站" },
[{ id: 1, code: "demo", path: "demo", name: "云海大学门户网站" }]
);
});
await flush();
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
setInputValue(input, "jdxy");
await flush();
expect(el.textContent).toContain("/portal/demo/jdxy/");
// 上级锁定为本校主站(Select disabled)
const selectEl = el.querySelector(".ivu-select-disabled");
expect(selectEl).toBeTruthy();
});
it("创建成功:自动进入第二步(皮肤 Tab + Steps + 保存并完成),全 Tab 开放", async () => {
const el = mountPage();
await flush();
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
setInputValue(input, "jdxy");
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
setInputValue(nameInput, "机械学院门户");
await flush();
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点,下一步"))!;
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush(10);
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const payload = mocks.siteSave.mock.calls[0][0];
expect(payload.code).toBe("jdxy");
expect(payload.name).toBe("机械学院门户");
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
// 向导第二步:Steps 可见 + 皮肤 Tab 激活 + 完成按钮;无灰态
expect(el.textContent).toContain("皮肤与外观");
expect(el.textContent).toContain("保存并完成");
expect(el.textContent).toContain("跳过,稍后配置");
expect(el.textContent).not.toContain("创建站点后开放");
});
});
describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", () => {
it("7 个 Tab 标题齐备(基本信息…自定义样式),基本信息回填,无灰态提示", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
["基本信息", "皮肤与外观", "首页布局", "基础展示", "页脚信息", "SEO 与域名", "自定义样式"].forEach(t =>
expect(el.textContent).toContain(t)
);
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
expect(el.textContent || "").not.toContain("未在注册表中");
expect(el.textContent).toContain("云海大学门户网站");
expect(el.textContent).not.toContain("创建站点后开放");
});
it("未注册皮肤保留原值并弹出告警(不静默归一 default)", async () => {
routeMock.params = { id: "1" };
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }])));
const el = mountPage();
await flush();
expect(el.textContent).toContain("未在注册表中");
expect(el.textContent).toContain("skin-x");
});
it("保存基本信息:以服务端事实源 configJson 提交(home_layout 等其他 Tab 键随行不丢)", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
setInputValue(nameInput, "改名后的门户");
await flush();
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const payload = mocks.siteSave.mock.calls[0][0];
expect(payload.id).toBe(1);
expect(payload.name).toBe("改名后的门户");
expect(payload.theme).toBe("default");
const config = JSON.parse(payload.configJson);
expect(config.logo).toBe("/cms/upload/logo.png");
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局 Tab 未动
});
it("T-T-05: 皮肤 Tab 只留选皮肤(无参数微调),保存不写 theme_params", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
// ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩
expect(el.textContent).not.toContain("theme-param-stub");
expect(el.textContent).not.toContain("参数微调");
expect(el.textContent).toContain("theme-picker-stub");
const saveBtn = [...el.querySelectorAll("button")].find(b =>
b.textContent?.includes("保存皮肤与外观")
)!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交
expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留
});
it("F1:自定义样式为独立 Tab 的代码编辑器(textarea + 字符计数),非折叠面板", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const cssTextarea = el.querySelector("textarea");
expect(cssTextarea).toBeTruthy();
expect(el.textContent).toContain("自定义样式");
expect(el.textContent).toContain("/10240 字符");
// 独立保存按钮
const cssSaveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存自定义样式"))!;
expect(cssSaveBtn).toBeTruthy();
cssSaveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson);
expect(config).not.toHaveProperty("custom_css"); // 空样式 = 删除键
expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响
});
});
describe("SiteSetting 假脏防护(docs/20 返工 #2)", () => {
it("冷加载不标脏:7 个 Tab 标题均无 ●(种值触发的 watcher 落在 seeding 窗口内)", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const labels = Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
expect(labels.length).toBe(7);
labels.forEach(t => expect(t.includes("●")).toBe(false));
});
it("真实编辑才标脏:改站点名称 → 基本信息 ●;保存成功后 ● 消失", async () => {
routeMock.params = { id: "1" };
const el = mountPage();
await flush();
const labels = () => Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
setInputValue(nameInput, "改名后的门户");
await flush();
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(true);
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(false);
});
});
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
/**
* F5(docs/20)页脚/基础展示视觉位置示意:
* ① 页脚分组底部渲染 FooterPreview 结构示意图(六处位置标注,已填内容回显);
* ② asset 字段的 aspect 占位框(favicon 1:1 / 二维码 4:3,无值虚线框、有值比例预览)。
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
vi.mock("@/api/module/cms", () => ({
assetUrl: (u: string) => u,
uploadUrl: () => "/api/attachment/upload",
}));
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
const cleanups: (() => void)[] = [];
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
await nextTick();
}
const FOOTER_CONFIG = JSON.stringify({
contact: "电话 0571-88888888",
footer_addresses: "潭山校区:南湖路 237 号\n淮河校区:大学路 1 号",
qr_wechat: "/cms/upload/qr-wechat.png",
social_weibo: "https://weibo.com/xxx",
copyright: "XX学校 · 版权所有 © 2026",
beian: "浙ICP备XXXXXXXX号-1",
logo: "/cms/upload/logo.png",
});
async function mountForm(configJson: string, groups?: string[]) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(configJson);
const app = createApp(
defineComponent({
render: () =>
h(SiteConfigForm, {
modelValue: model.value,
groups,
"onUpdate:modelValue": (v: string) => (model.value = v),
}),
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle();
return el;
}
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("F5 视觉位置示意(docs/20)", () => {
it("页脚分组:底部渲染结构示意图,六处位置标注 + 已填内容回显", async () => {
const el = await mountForm(FOOTER_CONFIG, ["footer"]);
const preview = el.querySelector(".footer-preview")!;
expect(preview).toBeTruthy();
const text = preview.textContent || "";
// 已填字段实时回显内容(而非占位标注)
expect(text).toContain("电话 0571-88888888");
expect(text).toContain("潭山校区:南湖路 237 号");
expect(text).toContain("XX学校 · 版权所有 © 2026");
expect(text).toContain("浙ICP备XXXXXXXX号-1");
expect(preview.querySelector('img[src="/cms/upload/qr-wechat.png"]')).toBeTruthy();
// 未填字段显示位置标注(微博二维码空 → 占位)
expect(text).toContain("微博二维码");
expect(text).toContain("社媒图标行");
});
it("页脚全空配置:六处位置标注完整(① 联系方式/② 地址/③ 二维码/④ 社媒/⑤ 版权/⑥ 备案)", async () => {
const el = await mountForm("{}", ["footer"]);
const text = el.querySelector(".footer-preview")!.textContent || "";
expect(text).toContain("① 联系方式");
expect(text).toContain("② 多校区地址");
expect(text).toContain("微信二维码");
expect(text).toContain("③ 4:3");
expect(text).toContain("④ 社媒图标行");
expect(text).toContain("⑤ 版权文字");
expect(text).toContain("⑥ 备案号");
});
it("基础展示分组:无示意图(仅页脚有)", async () => {
const el = await mountForm(FOOTER_CONFIG, ["display"]);
expect(el.querySelector(".footer-preview")).toBeFalsy();
});
it("aspect 占位框:二维码无值时 4:3 虚线占位、favicon 无值时 1:1 占位", async () => {
const el = await mountForm("{}", ["display", "footer"]);
const holders = el.querySelectorAll(".iaf-holder");
expect(holders.length).toBe(3); // favicon 1:1 + 微信/微博 4:3
expect(el.querySelector(".iaf-holder-1x1")).toBeTruthy();
expect(el.querySelectorAll(".iaf-holder-4x3").length).toBe(2);
expect(holders[0].textContent).toContain("1:1");
});
});
-160
View File
@@ -1,160 +0,0 @@
// @vitest-environment jsdom
/**
* W4 向导地址预览联动(方案 §8.3 T-W4-18):
* 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。
* 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
const themesMock = vi.hoisted(() => ({
list: vi.fn(async () => [
{
name: "default",
title: "云端默认",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [],
},
]),
}));
vi.mock("@/api/module/cms", () => ({
themeApi: { list: () => ({ send: themesMock.list }) },
siteApi: { save: () => ({ send: async () => ({}) }) },
}));
vi.mock("@/components/theme/ThemePicker.vue", () => ({
default: defineComponent({
props: ["themes", "modelValue"],
emits: ["update:modelValue", "pick"],
setup(_, { emit }) {
(globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name);
return () => h("div", { class: "theme-picker-stub" }, "themes");
},
}),
}));
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }),
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({
props: ["modelValue"],
methods: { validate: async () => true },
render: () => null,
}),
}));
import SiteWizard from "@/views/cms/SiteWizard.vue";
let cleanups: (() => void)[] = [];
async function flush(frames = 6) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
function mountWizard(lockParentId?: number, secondMain = false) {
const sites = [
{ id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 },
...(secondMain
? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }]
: []),
];
const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径)
const pinia = createPinia();
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp(defineComponent({
setup() {
return () => h(SiteWizard, {
visible: visible.value,
sites,
lockParentId,
"onUpdate:visible": (v: boolean) => { visible.value = v; },
});
},
}));
app.use(pinia);
app.use(ViewUIPlus);
// SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus
// 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem
app.component("FormItem", defineComponent({
props: ["label", "prop", "labelWidth"],
setup(props, { slots }) {
return () => h("div", { class: "form-item-stub" }, [
props.label ? h("label", props.label) : null,
slots.default?.(),
]);
},
}));
app.mount(el);
visible.value = true;
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
async function gotoStep3() {
// step1 → step2 → step3(footer 按钮 transfer 到 body)
const clickNext = async () => {
await flush();
const btn = [...document.querySelectorAll(".ivu-modal-footer button")]
.find(b => b.textContent?.includes("下一步"));
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
};
await clickNext();
await clickNext();
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("SiteWizard 门户地址预览(T-W4-18)", () => {
it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => {
mountWizard(1);
await gotoStep3();
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, "jdxy");
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("门户地址预览");
expect(document.body.textContent).toContain("/portal/sch-3/jdxy/");
});
it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => {
mountWizard(2, true);
await gotoStep3();
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input, "jdxy");
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("/portal/sch-8/jdxy/");
expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { resolveEditableTheme } from "@/utils/themeEdit";
/**
* 任务三(2026-09-08):站点编辑弹窗主题归一纯函数。
* 旧行为把「不在皮肤包列表中的主题」静默归一 default,保存即悄悄改写站点换肤;
* 新行为保留原值交服务端拒绝,仅标记告警。
*/
describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => {
it("注册中的主题原值保留且不告警", () => {
expect(resolveEditableTheme("scholar-blue", ["default", "scholar-blue"])).toEqual({
theme: "scholar-blue",
unregistered: false,
});
});
it("docs/20 E:下架主题解析为保留主题 + colorScheme 预设(不误报未注册)", () => {
expect(resolveEditableTheme("classic-red", ["default", "scholar-blue", "campus-glow"])).toEqual({
theme: "default",
unregistered: false,
aliasColorScheme: "classic-red",
});
expect(resolveEditableTheme("poly-blue", ["default", "scholar-blue", "campus-glow"])).toEqual({
theme: "scholar-blue",
unregistered: false,
aliasColorScheme: "poly",
});
});
it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => {
expect(resolveEditableTheme("ghost-theme", ["default", "scholar-blue"])).toEqual({
theme: "ghost-theme",
unregistered: true,
});
});
it("空主题归一 default(站点未选过皮肤的场景)", () => {
expect(resolveEditableTheme(undefined, ["default", "scholar-blue"])).toEqual({
theme: "default",
unregistered: false,
});
});
it("列表为空(加载失败)时保留原值且不误报,交服务端校验兜底", () => {
expect(resolveEditableTheme("mint-breeze", [])).toEqual({
theme: "mint-breeze",
unregistered: false,
});
});
});
+213 -26
View File
@@ -1,9 +1,13 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24): * W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24 + 任务二/四 2026-09-08):
* ① 内置卡无删除/编辑按钮(仅"预览"); * ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
* ② 被用外部包删除禁用且 tooltip 含站点名; * ② 被用外部包删除禁用且 tooltip 含站点名;
* ③ 上传成功刷新列表(list 被再次调用)+ Message.success。 * ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
* ④ 内置编辑弹窗不开放参数 schema 区、保存不带 params(后端强校验拒改);
* ⑤ 添加皮肤包表单标识名为空时拦截。
* ⑥ 工作项 T(T-T-06):编辑弹窗内嵌参数微调(内置包亦开放),回显/提交包级 paramValues,
* 未触碰不带 paramValues(null=不改),卡片显示「已微调 M 项」。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。 * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -11,26 +15,32 @@ import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import ViewUIPlus, { Message } from "view-ui-plus"; import ViewUIPlus, { Message } from "view-ui-plus";
const { listMock, uploadMock, removeMock } = vi.hoisted(() => ({ const { listMock, uploadMock, removeMock, deriveMock, updateMetaMock } = vi.hoisted(() => ({
listMock: vi.fn(), listMock: vi.fn(),
uploadMock: vi.fn(), uploadMock: vi.fn(),
removeMock: vi.fn(), removeMock: vi.fn(),
deriveMock: vi.fn(),
updateMetaMock: vi.fn(),
})); }));
const themes = [ const themes = [
{ {
name: "default", name: "default",
title: "云端默认", title: "云端默认",
description: "",
screenshotUrl: "/api/theme/default/asset/screenshot.png", screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }], params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
// 工作项 T:包级参数值(仅含被微调过的 key)
paramValues: { primary: "#0a5c36" },
source: "builtin", source: "builtin",
inUseCount: 2, inUseCount: 2,
editable: false, editable: true,
usedBySiteNames: ["第一中学门户", "机电学院"], usedBySiteNames: ["第一中学门户", "机电学院"],
}, },
{ {
name: "my-skin", name: "my-skin",
title: "我的皮肤", title: "我的皮肤",
description: "",
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png", screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }], params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
source: "external", source: "external",
@@ -41,6 +51,7 @@ const themes = [
{ {
name: "free-skin", name: "free-skin",
title: "空闲皮肤", title: "空闲皮肤",
description: "",
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png", screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
params: [], params: [],
source: "external", source: "external",
@@ -53,16 +64,22 @@ const themes = [
vi.mock("@/api/module/cms", () => ({ vi.mock("@/api/module/cms", () => ({
themeApi: { themeApi: {
list: () => ({ send: listMock }), list: () => ({ send: listMock }),
upload: (file: File) => { upload: (file: File, meta?: unknown) => {
uploadMock(file); uploadMock(file, meta);
return { send: async () => ({}) }; return { send: async () => ({}) };
}, },
remove: (name: string) => { remove: (name: string) => {
removeMock(name); removeMock(name);
return { send: async () => ({}) }; return { send: async () => ({}) };
}, },
derive: () => ({ send: async () => ({}) }), derive: (source: string, name: string, meta?: unknown) => {
updateMeta: () => ({ send: async () => ({}) }), deriveMock(source, name, meta);
return { send: async () => ({}) };
},
updateMeta: (name: string, data: unknown) => {
updateMetaMock(name, data);
return { send: async () => ({}) };
},
reload: () => ({ send: async () => ({}) }), reload: () => ({ send: async () => ({}) }),
}, },
siteApi: { list: () => ({ send: async () => [] }) }, siteApi: { list: () => ({ send: async () => [] }) },
@@ -70,7 +87,45 @@ vi.mock("@/api/module/cms", () => ({
})); }));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }), default: defineComponent({
props: ["sitePath", "theme", "themeParams", "fill"],
setup: (props: any) => () =>
h(
"div",
{
"data-preview-stub": "true",
"data-fill": String(props.fill !== undefined && props.fill !== false),
"data-params": JSON.stringify(props.themeParams || {}),
},
"preview-stub"
),
}),
}));
/**
* T-T-06:参数微调迁皮肤包级后,编辑弹窗内嵌 ThemeParamForm。
* 桩件把当前表单模型回显在 data-model 上,并给一个「改主色」按钮模拟用户改动。
*/
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
default: defineComponent({
props: ["theme", "modelValue"],
emits: ["update:modelValue"],
setup: (props: any, { emit }: any) => () =>
h(
"div",
{ "data-param-stub": "true", "data-model": JSON.stringify(props.modelValue || {}) },
[
h(
"button",
{
class: "stub-tweak",
onClick: () => emit("update:modelValue", { ...(props.modelValue || {}), primary: "#111111" }),
},
"改主色"
),
]
),
}),
})); }));
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() })); vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
@@ -101,6 +156,17 @@ function mountPage() {
return el; return el;
} }
function clickButtonByText(root: ParentNode, text: string): HTMLElement | undefined {
const btn = [...root.querySelectorAll("button")].find(b => b.textContent?.includes(text));
btn?.click();
return btn;
}
/** 页面有多个 Modal 常驻 DOM,按特征文案定位目标弹窗 */
function modalContaining(text: string): HTMLElement | undefined {
return [...document.querySelectorAll(".ivu-modal")].find(m => m.textContent?.includes(text));
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes))); listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
@@ -111,8 +177,8 @@ afterEach(() => {
cleanups = []; cleanups = [];
}); });
describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => { describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
it("T-W1-22: 内置卡无删除/编辑按钮(仅'预览')", async () => { it("T-W1-22: 内置卡按钮为'预览+编辑'(任务四起内置可改元数据),无删除", async () => {
const el = mountPage(); const el = mountPage();
await flush(); await flush();
@@ -121,7 +187,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
const builtin = cards[0]; const builtin = cards[0];
expect(builtin.textContent).toContain("内置"); expect(builtin.textContent).toContain("内置");
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim()); const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
expect(btns).toEqual(["预览"]); expect(btns).toEqual(["预览", "编辑"]);
}); });
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => { it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
@@ -144,31 +210,152 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
expect(freeDelete!.getAttribute("disabled")).toBeNull(); expect(freeDelete!.getAttribute("disabled")).toBeNull();
}); });
it("T-W1-24: 上传成功刷新列表(list 再次调用)+ Message.success", async () => { it("T-W1-24: 上传表单弹窗——未选文件时上传禁用;选文件提交调用 upload 并刷新列表", async () => {
const el = mountPage(); const el = mountPage();
await flush(); await flush();
expect(listMock).toHaveBeenCalledTimes(1); expect(listMock).toHaveBeenCalledTimes(1);
const successSpy = vi.spyOn(Message, "success"); const successSpy = vi.spyOn(Message, "success");
// Upload 组件经 before-upload 手动上传:直接调内部通道(beforeUpload 闭包不可达, clickButtonByText(el, "上传皮肤包");
// 经组件 props 不可得 → 以 uploadMock + list 重查语义等价驱动:触发 Upload 的 change? await flush();
// 简化:直接验证上传成功路径——页面暴露 beforeUpload 经 Upload 组件,stub 层以事件驱动)
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
// 通过 Upload 的 before-upload 触发:dispatch 到上传按钮内部 input 不稳定,
// 这里改为验证「上传成功 → Message.success + list 重查」的最小可观测路径:
(el as any).__themeUpload = (f: File) => f; // 占位,防误用
const uploadBtn = [...el.querySelectorAll(".toolbar button")].find(b => b.textContent?.includes("上传皮肤包"));
expect(uploadBtn).toBeTruthy();
// 直接调用组件内通道的等价方式:Upload before-upload 经 ivu Upload 的 input change // 弹窗渲染到 body:未选文件时上传按钮禁用
const input = el.querySelector<HTMLInputElement>('input[type="file"]'); const uploadModal = modalContaining("皮肤包 zip");
expect(uploadModal).toBeTruthy();
const submit = [...uploadModal!.querySelectorAll(".ivu-modal-footer button")].find(b =>
b.textContent?.includes("上传")
);
expect(submit).toBeTruthy();
expect(submit!.getAttribute("disabled")).not.toBeNull();
// 选择文件(Upload before-upload 通道)→ 按钮解锁 → 提交
const input = uploadModal!.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy(); expect(input).toBeTruthy();
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
Object.defineProperty(input!, "files", { value: [file] }); Object.defineProperty(input!, "files", { value: [file] });
input!.dispatchEvent(new Event("change")); input!.dispatchEvent(new Event("change"));
await flush(); await flush();
expect(document.body.textContent).toContain("已选择:pkg.zip");
expect(uploadMock).toHaveBeenCalled(); submit!.click();
await flush();
expect(uploadMock).toHaveBeenCalledTimes(1);
expect(uploadMock.mock.calls[0][0]).toBe(file);
expect(successSpy).toHaveBeenCalled(); expect(successSpy).toHaveBeenCalled();
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2); expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
}); });
it("任务四: 内置包编辑弹窗不开放参数 schema 编辑区,保存调用 updateMeta 不带 params", async () => {
const el = mountPage();
await flush();
const builtin = [...el.querySelectorAll(".card")][0];
clickButtonByText(builtin, "编辑");
await flush();
// 内置提示条存在、无 ColorPicker(参数 schema 编辑区不渲染;包级参数值是另一区,见 T-T-06)
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
expect(metaModal).toBeTruthy();
expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改");
expect(metaModal!.querySelectorAll(".ivu-color-picker").length).toBe(0);
clickButtonByText(metaModal!, "保存");
await flush();
expect(updateMetaMock).toHaveBeenCalledWith(
"default",
expect.objectContaining({ title: "云端默认", params: undefined })
);
});
it("T-T-06: 编辑弹窗内嵌参数微调,回显包级 paramValues(缺项回落默认),保存提交 paramValues", async () => {
const el = mountPage();
await flush();
clickButtonByText([...el.querySelectorAll(".card")][0], "编辑");
await flush();
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
const stub = metaModal!.querySelector('[data-param-stub]');
expect(stub).toBeTruthy();
// paramValuesOf:已微调的 primary 取包值,未声明微调的走 theme.json 默认
expect(JSON.parse(stub!.getAttribute("data-model")!)).toEqual({ primary: "#0a5c36" });
// 改主色 → 保存:只提交「与默认值不同」的 key(=「已微调」口径,与后端整体替换语义对齐)
clickButtonByText(metaModal!, "改主色");
await flush();
clickButtonByText(metaModal!, "保存");
await flush();
expect(updateMetaMock).toHaveBeenCalledWith("default", expect.objectContaining({ paramValues: { primary: "#111111" } }));
});
it("T-T-06: 未触碰参数区时保存不带 paramValues(后端 null=不改,防止改名顺带清空微调)", async () => {
const el = mountPage();
await flush();
clickButtonByText([...el.querySelectorAll(".card")][2], "编辑"); // free-skin(外部包,无微调)
await flush();
const metaModal = modalContaining("编辑皮肤:free-skin");
expect(metaModal).toBeTruthy();
expect(JSON.parse(metaModal!.querySelector('[data-param-stub]')!.getAttribute("data-model")!)).toEqual({});
clickButtonByText(metaModal!, "保存");
await flush();
const payload = updateMetaMock.mock.calls[0][1] as Record<string, unknown>;
expect("paramValues" in payload).toBe(false);
});
it("T-T-06: 卡片显示「已微调 M 项」,无微调的包显示未微调", async () => {
const el = mountPage();
await flush();
const cards = [...el.querySelectorAll(".card")];
expect(cards[0].textContent).toContain("参数 1 个 · 已微调 1 项");
expect(cards[1].textContent).toContain("未微调"); // my-skin 无 paramValues
expect(cards[2].textContent).toContain("参数 0 个");
});
it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => {
const el = mountPage();
await flush();
const warnSpy = vi.spyOn(Message, "warning");
clickButtonByText(el, "添加皮肤包");
await flush();
const addModal = modalContaining("复制自");
expect(addModal).toBeTruthy();
clickButtonByText(addModal!, "添加");
await flush();
expect(warnSpy).toHaveBeenCalled();
expect(deriveMock).not.toHaveBeenCalled();
});
it("2026-09-10: 预览弹窗全屏展示(fullscreen + 无 footer),预览框 fill 占满余量", async () => {
const el = mountPage();
await flush();
const builtin = [...el.querySelectorAll(".card")][0];
clickButtonByText(builtin, "预览");
await flush();
const previewModal = modalContaining("内容源站点");
expect(previewModal).toBeTruthy();
expect(previewModal!.className).toContain("ivu-modal-fullscreen");
expect(previewModal!.className).toContain("ivu-modal-fullscreen-no-footer");
const stub = previewModal!.querySelector("[data-preview-stub]");
expect(stub).toBeTruthy();
expect(stub!.getAttribute("data-fill")).toBe("true");
});
it("T-T-06 佐证: 预览转发包级 paramValues(后端 /api/theme/preview 只认 p_*,不转发则预览丢包色)", async () => {
const el = mountPage();
await flush();
clickButtonByText([...el.querySelectorAll(".card")][0], "预览"); // default:paramValues={primary:#0a5c36}
await flush();
const stub = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
expect(JSON.parse(stub.getAttribute("data-params")!)).toEqual({ primary: "#0a5c36" });
clickButtonByText([...el.querySelectorAll(".card")][2], "预览"); // free-skin:未微调
await flush();
const free = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!;
expect(JSON.parse(free.getAttribute("data-params")!)).toEqual({});
});
}); });
+40 -9
View File
@@ -14,6 +14,7 @@ import {
DeleteRequest, DeleteRequest,
LinkVo, LinkVo,
PageResult, PageResult,
PersonVo,
SchoolCard, SchoolCard,
SchoolOption, SchoolOption,
SiteVo, SiteVo,
@@ -34,6 +35,7 @@ const ARTICLE = "/api/article";
const ATTACHMENT = "/api/attachment"; const ATTACHMENT = "/api/attachment";
const BANNER = "/api/banner"; const BANNER = "/api/banner";
const LINK = "/api/link"; const LINK = "/api/link";
const PERSON = "/api/person";
const THEME = "/api/theme"; const THEME = "/api/theme";
/** GET(query 参数) */ /** GET(query 参数) */
@@ -106,7 +108,8 @@ class SiteApi {
return get<SiteVo[]>(`${SITE}/list`); return get<SiteVo[]>(`${SITE}/list`);
} }
/** 入参对齐 SiteSaveRequest(themeParams 服务端合并进 config_json.theme_params,方案 §5.3) */ /** 入参对齐 SiteSaveRequest。themeParams 已随工作项 T 退役(参数值改存皮肤包级 paramValues),
* 服务端忽略该字段——保留仅为与 @Deprecated 的后端 DTO 同形,新代码不要再传 */
save(data: Partial<SiteVo> & { themeParams?: Record<string, string> }) { save(data: Partial<SiteVo> & { themeParams?: Record<string, string> }) {
return postJSON<SiteVo>(`${SITE}/save`, data); return postJSON<SiteVo>(`${SITE}/save`, data);
} }
@@ -207,26 +210,52 @@ class LinkApi {
} }
} }
/** 名师风采人物(docs/20 D-7) */
class PersonApi {
list(siteId: number) {
return get<PersonVo[]>(`${PERSON}/list`, { siteId });
}
save(data: Partial<PersonVo>) {
return postJSON<PersonVo>(`${PERSON}/save`, data);
}
delete(data: DeleteRequest) {
return postJSON<void>(`${PERSON}/delete`, data);
}
}
class ThemeApi { class ThemeApi {
/** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */ /** 可用主题列表(含 params schema,建站向导/站点编辑共用,方案 §5.2) */
list() { list() {
return get<ThemeVo[]>(`${THEME}/list`); return get<ThemeVo[]>(`${THEME}/list`);
} }
/** W1:上传合规 zip(multipart,超管;免重启立即注册)。
/** W1:上传合规 zip(multipart,超管;免重启立即注册) */ * 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */
upload(file: File) { upload(file: File, meta?: { title?: string; description?: string }) {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
if (meta?.title) fd.append("title", meta.title);
if (meta?.description !== undefined && meta.description !== null) {
fd.append("description", meta.description);
}
return alovaInstance.Post<ThemeVo>(`${THEME}/upload`, fd); return alovaInstance.Post<ThemeVo>(`${THEME}/upload`, fd);
} }
/** W1:从现有包派生新皮肤(复制包体+改名) */ /** W1:从现有包派生新皮肤(复制包体+改名);任务四支持同步命名(title/description 选填) */
derive(source: string, name: string) { derive(source: string, name: string, meta?: { title?: string; description?: string }) {
return postJSON<ThemeVo>(`${THEME}/derive`, { source, name }); return postJSON<ThemeVo>(`${THEME}/derive`, { source, name, ...meta });
} }
/** W1:编辑外部包 theme.json 的 title/参数 schema */ /** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
updateMeta(name: string, data: { title?: string; params?: unknown[] }) { * 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认)。
* 工作项 T:paramValues=包级参数微调值,省略=不改,{}=清空恢复 theme.json 默认值 */
updateMeta(name: string, data: {
title?: string;
description?: string;
params?: unknown[];
paramValues?: Record<string, string>;
}) {
return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, { return alovaInstance.Put<ThemeVo>(`${THEME}/${name}/meta`, data, {
headers: { "Content-Type": "application/json;charset=UTF-8" }, headers: { "Content-Type": "application/json;charset=UTF-8" },
}); });
@@ -259,6 +288,7 @@ export const categoryApi = new CategoryApi();
export const articleApi = new ArticleApi(); export const articleApi = new ArticleApi();
export const attachmentApi = new AttachmentApi(); export const attachmentApi = new AttachmentApi();
export const bannerApi = new BannerApi(); export const bannerApi = new BannerApi();
export const personApi = new PersonApi();
export const linkApi = new LinkApi(); export const linkApi = new LinkApi();
export const themeApi = new ThemeApi(); export const themeApi = new ThemeApi();
@@ -271,6 +301,7 @@ export default {
articleApi, articleApi,
attachmentApi, attachmentApi,
bannerApi, bannerApi,
personApi,
linkApi, linkApi,
themeApi, themeApi,
uploadUrl, uploadUrl,
+40 -10
View File
@@ -1,24 +1,54 @@
import { BaseConf } from "@/types/Basics"; import { BaseConf } from "@/types/Basics";
/** /**
* 通过 Vite 环境变量管理配置(Notice front 同模式) * 配置优先级(高 → 低):
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.prod 中 * ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* serviceHost = 源 + 后端上下文路径(如 http://base.yunhaixingtong.com/cms), * /<contextPath>/api/env-config?target=<部署目录名>(默认 admin,projman 配置管理维护)写入
* dev 模式留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头) * ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
* build 产物不读任何 .env.*(.env.base/.env.prod 已删),VITE_* 恒为 undefined,本层自动跳过,
* 产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
* ssoHost 为空 → 登录跳转不可用——与后端未就绪状态一致,可接受。
*/ */
const env = import.meta.env; const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/admin/ → contextPath,即后端上下文),
// 与服务端 context-path 对齐(本地 /cms、base/prod /yschcms);本地 dev/mock 在根路径运行(无段),
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
const seg = window.location.pathname.split("/").filter(Boolean);
const origin = window.location.origin;
let conf: BaseConf = { let conf: BaseConf = {
baseHost: env.VITE_BASE_HOST, baseHost: origin,
ssoHost: env.VITE_SSO_HOST, ssoHost: "",
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST, serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
isMock: env.VITE_IS_MOCK === "true", isMock: false,
isDebug: env.DEV, isDebug: env.DEV,
}; };
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
const applyConf = (target: any, source: any) => {
if (!source || typeof source !== "object") return;
Object.keys(source).forEach(key => {
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
(target as any)[key] = source[key];
}
});
};
// ② 本地开发/演示(build 产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_SERVICE_HOST,
isMock: env.VITE_IS_MOCK === "true",
});
// ① 线上运行时配置(最高优先,覆盖前面所有)
applyConf(conf, (window as any).__APP_CONFIG__);
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https:// // HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false; if ("https:" === document.location.protocol) {
if (ishttps) {
Object.keys(conf).forEach(element => { Object.keys(conf).forEach(element => {
const val = (conf as any)[element]; const val = (conf as any)[element];
if (typeof val === "string" && val.indexOf("http://") > -1) { if (typeof val === "string" && val.indexOf("http://") > -1) {
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

File diff suppressed because one or more lines are too long
+110
View File
@@ -0,0 +1,110 @@
/*
* 规范 iconfont 图标本地实现(bsch 应用页规范 §2 操作列 / 布局头尾图标)。
* 平台 base.layout.beta 主题不含 iconfont 字体文件,且新旧 BschHome 均已不再提供
* icon-browse 一类的字形,这里以 SVG mask + currentColor 实现同名类:
* 颜色跟随文字色,天然支持 .operating-btn:hover 圆形反色效果。
* 后续若接入平台真实 iconfont,可整文件移除。
*/
.iconfont {
font-family: inherit;
font-style: normal;
font-size: 16px;
line-height: 1;
display: inline-block;
vertical-align: middle;
}
.iconfont::before {
content: "" !important;
display: block;
width: 1em;
height: 1em;
background-color: currentColor;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.icon-browse::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M512 256C299 256 121 361 32 512c89 151 267 256 480 256s391-105 480-256c-89-151-267-256-480-256zm0 416c-88 0-160-72-160-160s72-160 160-160 160 72 160 160-72 160-160 160z'/%3E%3Ccircle cx='512' cy='512' r='80'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M512 256C299 256 121 361 32 512c89 151 267 256 480 256s391-105 480-256c-89-151-267-256-480-256zm0 416c-88 0-160-72-160-160s72-160 160-160 160 72 160 160-72 160-160 160z'/%3E%3Ccircle cx='512' cy='512' r='80'/%3E%3C/svg%3E");
}
.icon-edit::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M768 128c18 0 35 7 49 21l58 58c27 27 27 71 0 98L448 833l-200 54c-16 4-31-11-27-27l54-200L803 149c14-14 31-21 49-21zM128 896h768v48H128z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M768 128c18 0 35 7 49 21l58 58c27 27 27 71 0 98L448 833l-200 54c-16 4-31-11-27-27l54-200L803 149c14-14 31-21 49-21zM128 896h768v48H128z'/%3E%3C/svg%3E");
}
.icon-ashbin::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M416 96h192l32 64h160c18 0 32 14 32 32s-14 32-32 32H224c-18 0-32-14-32-32s14-32 32-32h160l32-64zM224 288h576l-36 520c-3 46-41 82-88 82H348c-47 0-85-36-88-82l-36-520zm176 64c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32zm224 0c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M416 96h192l32 64h160c18 0 32 14 32 32s-14 32-32 32H224c-18 0-32-14-32-32s14-32 32-32h160l32-64zM224 288h576l-36 520c-3 46-41 82-88 82H348c-47 0-85-36-88-82l-36-520zm176 64c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32zm224 0c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32z'/%3E%3C/svg%3E");
}
.icon-user-filling::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 128a176 176 0 1 1 0 352 176 176 0 0 1 0-352zm0 416c190 0 320 104 320 224v32c0 26-21 48-48 48H240c-27 0-48-22-48-48v-32c0-120 130-224 320-224z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 128a176 176 0 1 1 0 352 176 176 0 0 1 0-352zm0 416c190 0 320 104 320 224v32c0 26-21 48-48 48H240c-27 0-48-22-48-48v-32c0-120 130-224 320-224z'/%3E%3C/svg%3E");
}
.icon-attachment::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M640 64H256c-35 0-64 29-64 64v768c0 35 29 64 64 64h512c35 0 64-29 64-64V256L640 64zm-16 112l144 144h-144V176zM320 448h384v48H320v-48zm0 128h384v48H320v-48zm0 128h384v48H320v-48z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M640 64H256c-35 0-64 29-64 64v768c0 35 29 64 64 64h512c35 0 64-29 64-64V256L640 64zm-16 112l144 144h-144V176zM320 448h384v48H320v-48zm0 128h384v48H320v-48zm0 128h384v48H320v-48z'/%3E%3C/svg%3E");
}
.icon-apps::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M224 128h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V160c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V160c0-18 14-32 32-32zM224 480h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V512c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V512c0-18 14-32 32-32z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M224 128h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V160c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V160c0-18 14-32 32-32zM224 480h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V512c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V512c0-18 14-32 32-32z'/%3E%3C/svg%3E");
}
.icon-logout::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M544 96c18 0 32 14 32 32v96h-64v-64H256v704h256v-64h64v96c0 18-14 32-32 32H224c-18 0-32-14-32-32V128c0-18 14-32 32-32h320zm208 416L600 664l-45-45 75-75H448v-64h182l-75-75 45-45 152 152z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M544 96c18 0 32 14 32 32v96h-64v-64H256v704h256v-64h64v96c0 18-14 32-32 32H224c-18 0-32-14-32-32V128c0-18 14-32 32-32h320zm208 416L600 664l-45-45 75-75H448v-64h182l-75-75 45-45 152 152z'/%3E%3C/svg%3E");
}
.icon-category::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 96l448 384h-112v352c0 18-14 32-32 32H608V608H416v256H208c-18 0-32-14-32-32V480H64l448-384z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 96l448 384h-112v352c0 18-14 32-32 32H608V608H416v256H208c-18 0-32-14-32-32V480H64l448-384z'/%3E%3C/svg%3E");
}
/* 停用(圆圈斜杠),字形取自 bsch iconfont icon-stop(\e6e0) */
.icon-stop::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM885.333333 512c0 85.333333-29.866667 164.266667-78.933333 228.266667l-533.333333-514.133334c64-55.466667 149.333333-87.466667 238.933333-87.466666 204.8 0 373.333333 168.533333 373.333333 373.333333z m-746.666666 0c0-91.733333 34.133333-174.933333 87.466666-241.066667l535.466667 516.266667c-66.133333 59.733333-153.6 98.133333-251.733333 98.133333-202.666667 0-371.2-168.533333-371.2-373.333333z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM885.333333 512c0 85.333333-29.866667 164.266667-78.933333 228.266667l-533.333333-514.133334c64-55.466667 149.333333-87.466667 238.933333-87.466666 204.8 0 373.333333 168.533333 373.333333 373.333333z m-746.666666 0c0-91.733333 34.133333-174.933333 87.466666-241.066667l535.466667 516.266667c-66.133333 59.733333-153.6 98.133333-251.733333 98.133333-202.666667 0-371.2-168.533333-371.2-373.333333z'/%3E%3C/svg%3E");
}
/* ============ 通知模块图标(2026-08-31 与 oldfront 对齐,仅保留用到的) ============
* 附件类型字形(word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi)取自 oldfront/src/assets/js/iconfont/iconfont.js 同名 symbol,
* icon-saoba(一键已读)/icon-print(打印)平台原字体已失传,按同规格重绘。
*/
.icon-saoba::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M832 64L960 192 640 512 512 384 832 64zM512 384L640 512C560 704 448 848 256 960 192 992 128 976 96 928 64 880 80 816 128 768 256 608 384 480 512 384z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M832 64L960 192 640 512 512 384 832 64zM512 384L640 512C560 704 448 848 256 960 192 992 128 976 96 928 64 880 80 816 128 768 256 608 384 480 512 384z'/%3E%3C/svg%3E");
}
.icon-print::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M352 64h320c18 0 32 14 32 32v160H320V96c0-18 14-32 32-32zM224 320h576c35 0 64 29 64 64v224c0 35-29 64-64 64h-96v-96c0-18-14-32-32-32H384c-18 0-32 14-32 32v96h-96c-35 0-64-29-64-64V384c0-35 29-64 64-64zM352 512h96v64c0 18 14 32 32 32h64c18 0 32-14 32-32v-64h96v352c0 18-14 32-32 32H352c-18 0-32-14-32-32V544c0-18 14-32 32-32z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M352 64h320c18 0 32 14 32 32v160H320V96c0-18 14-32 32-32zM224 320h576c35 0 64 29 64 64v224c0 35-29 64-64 64h-96v-96c0-18-14-32-32-32H384c-18 0-32 14-32 32v96h-96c-35 0-64-29-64-64V384c0-35 29-64 64-64zM352 512h96v64c0 18 14 32 32 32h64c18 0 32-14 32-32v-64h96v352c0 18-14 32-32 32H352c-18 0-32-14-32-32V544c0-18 14-32 32-32z'/%3E%3C/svg%3E");
}
.icon-word::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z M329.728 428.032l81.158144 282.95168h48.254976l54.833152-212.762624h2.195456l54.833152 212.762624h48.254976L700.416 428.032h-52.641792l-52.641792 214.953984h-2.195456L538.103808 428.032h-48.254976l-52.641792 214.953984h-2.195456L382.369792 428.032z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z M329.728 428.032l81.158144 282.95168h48.254976l54.833152-212.762624h2.195456l54.833152 212.762624h48.254976L700.416 428.032h-52.641792l-52.641792 214.953984h-2.195456L538.103808 428.032h-48.254976l-52.641792 214.953984h-2.195456L382.369792 428.032z'/%3E%3C/svg%3E");
}
.icon-excel::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M246.784 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H246.784c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M438.9888 716.8l91.725824-131.657728L622.915584 716.8h43.708416l-114.704384-159.62112L658.366464 409.6h-43.708416l-83.943424 119.607296L447.1808 409.6H403.488768l105.590784 147.57888L395.264 716.8h43.7248z M656.384 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M246.784 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H246.784c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M438.9888 716.8l91.725824-131.657728L622.915584 716.8h43.708416l-114.704384-159.62112L658.366464 409.6h-43.708416l-83.943424 119.607296L447.1808 409.6H403.488768l105.590784 147.57888L395.264 716.8h43.7248z M656.384 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z'/%3E%3C/svg%3E");
}
.icon-ppt::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M428.335104 745.472v-125.636608h89.501696c70.234112 0 105.7792-30.547968 105.7792-91.213824 0-60.235776-35.119104-90.353664-105.34912-90.353664H393.216v307.2l35.119104 0.004096z m87.793664-156.18048h-87.793664v-120.471552h87.793664c24.408064 0 42.3936 4.734976 54.382592 15.065088 11.993088 9.03168 17.985536 24.088576 17.985536 44.7488 0 20.647936-5.988352 35.704832-17.563648 45.604864-11.984896 9.89184-29.970432 15.0528-54.808576 15.0528h0.004096z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M428.335104 745.472v-125.636608h89.501696c70.234112 0 105.7792-30.547968 105.7792-91.213824 0-60.235776-35.119104-90.353664-105.34912-90.353664H393.216v307.2l35.119104 0.004096z m87.793664-156.18048h-87.793664v-120.471552h87.793664c24.408064 0 42.3936 4.734976 54.382592 15.065088 11.993088 9.03168 17.985536 24.088576 17.985536 44.7488 0 20.647936-5.988352 35.704832-17.563648 45.604864-11.984896 9.89184-29.970432 15.0528-54.808576 15.0528h0.004096z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z'/%3E%3C/svg%3E");
}
.icon-pdf1::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M230.4 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H230.4c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M645.042176 674.586624c-27.643904-2.052096-54.255616-12.316672-75.75552-30.797824-41.971712 9.23648-81.903616 22.581248-121.831424 39.010304-31.735808 56.459264-61.423616 85.200896-87.01952 85.200896-5.12 0-11.259904-1.024-15.355904-4.108288-11.264-5.128192-17.403904-16.42496-17.403904-27.713536 0-9.240576 2.048-34.902016 99.303424-76.996608 22.528-41.0624 39.923712-83.1488 54.259712-127.287296-12.288-24.63744-38.899712-85.204992-20.48-115.99872 6.144-11.292672 18.432-17.453056 31.744-16.42496 10.235904 0 20.471808 5.132288 26.615808 13.340672 13.312 18.477056 12.288 57.48736-5.12 114.97472a309.497856 309.497856 0 0 0 63.471616 82.1248c21.504-4.112384 42.995712-7.18848 64.495616-7.18848 48.115712 1.024 55.283712 23.609344 54.259712 36.958208 0.008192 34.906112-33.779712 34.906112-51.183616 34.906112z m-286.646272 63.639552l3.072-1.024c14.331904-5.128192 25.587712-15.396864 33.779712-28.745728-15.36 6.160384-27.643904 16.42496-36.851712 29.773824v-0.004096z m136.155136-307.95776h-3.072c-1.024 0-3.072 0-4.096 1.024-4.096 17.44896-1.024 35.926016 6.144 52.350976 6.144-17.444864 6.144-35.926016 1.024-53.374976z m7.168 148.84864l-1.024 2.048-1.024-1.024c-9.216 23.61344-19.456 47.222784-30.715904 69.808128l2.048-1.024v2.048a594.771968 594.771968 0 0 1 69.615616-20.529152l-1.024-1.024h3.072c-15.355904-15.405056-29.687808-32.854016-40.947712-50.302976z m139.227136 54.407168c-9.216 0-17.403904 0-26.619904 2.048 10.24 5.136384 20.48 7.18848 30.715904 8.216576 7.168 1.024 14.336 0 20.475904-2.052096 0-3.084288-4.096-8.21248-24.571904-8.21248z M640 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M230.4 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H230.4c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M645.042176 674.586624c-27.643904-2.052096-54.255616-12.316672-75.75552-30.797824-41.971712 9.23648-81.903616 22.581248-121.831424 39.010304-31.735808 56.459264-61.423616 85.200896-87.01952 85.200896-5.12 0-11.259904-1.024-15.355904-4.108288-11.264-5.128192-17.403904-16.42496-17.403904-27.713536 0-9.240576 2.048-34.902016 99.303424-76.996608 22.528-41.0624 39.923712-83.1488 54.259712-127.287296-12.288-24.63744-38.899712-85.204992-20.48-115.99872 6.144-11.292672 18.432-17.453056 31.744-16.42496 10.235904 0 20.471808 5.132288 26.615808 13.340672 13.312 18.477056 12.288 57.48736-5.12 114.97472a309.497856 309.497856 0 0 0 63.471616 82.1248c21.504-4.112384 42.995712-7.18848 64.495616-7.18848 48.115712 1.024 55.283712 23.609344 54.259712 36.958208 0.008192 34.906112-33.779712 34.906112-51.183616 34.906112z m-286.646272 63.639552l3.072-1.024c14.331904-5.128192 25.587712-15.396864 33.779712-28.745728-15.36 6.160384-27.643904 16.42496-36.851712 29.773824v-0.004096z m136.155136-307.95776h-3.072c-1.024 0-3.072 0-4.096 1.024-4.096 17.44896-1.024 35.926016 6.144 52.350976 6.144-17.444864 6.144-35.926016 1.024-53.374976z m7.168 148.84864l-1.024 2.048-1.024-1.024c-9.216 23.61344-19.456 47.222784-30.715904 69.808128l2.048-1.024v2.048a594.771968 594.771968 0 0 1 69.615616-20.529152l-1.024-1.024h3.072c-15.355904-15.405056-29.687808-32.854016-40.947712-50.302976z m139.227136 54.407168c-9.216 0-17.403904 0-26.619904 2.048 10.24 5.136384 20.48 7.18848 30.715904 8.216576 7.168 1.024 14.336 0 20.475904-2.052096 0-3.084288-4.096-8.21248-24.571904-8.21248z M640 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
}
.icon-txt::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M238.592 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H238.592c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M540.11904 741.376v-276.652032h108.07296V434.176H397.312v30.547968h108.07296V741.376h34.73408z M648.192 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M238.592 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H238.592c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M540.11904 741.376v-276.652032h108.07296V434.176H397.312v30.547968h108.07296V741.376h34.73408z M648.192 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
}
.icon-tupian1::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M329.703424 716.53376l74.645504-109.71136a11.603968 11.603968 0 0 1 17.73568-1.748992l49.332224 48.3328 108.2368-169.652224a11.599872 11.599872 0 0 1 19.84512 0.446464l134.483968 233.0624a11.599872 11.599872 0 0 1-10.05568 17.412096H339.304448a11.599872 11.599872 0 0 1-9.60512-18.14528l0.004096 0.004096z M352.055296 468.115456c-0.004096 32.31744 26.189824 58.519552 58.507264 58.523648 32.31744 0.004096 58.519552-26.189824 58.523648-58.507264v-0.016384c0.004096-32.31744-26.189824-58.519552-58.507264-58.523648s-58.519552 26.189824-58.523648 58.507264v0.016384z M660.48 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M329.703424 716.53376l74.645504-109.71136a11.603968 11.603968 0 0 1 17.73568-1.748992l49.332224 48.3328 108.2368-169.652224a11.599872 11.599872 0 0 1 19.84512 0.446464l134.483968 233.0624a11.599872 11.599872 0 0 1-10.05568 17.412096H339.304448a11.599872 11.599872 0 0 1-9.60512-18.14528l0.004096 0.004096z M352.055296 468.115456c-0.004096 32.31744 26.189824 58.519552 58.507264 58.523648 32.31744 0.004096 58.519552-26.189824 58.523648-58.507264v-0.016384c0.004096-32.31744-26.189824-58.519552-58.507264-58.523648s-58.519552 26.189824-58.523648 58.507264v0.016384z M660.48 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
}
.icon-yasuo::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M588.8 630.378496v111.24736a27.783168 27.783168 0 0 1-8.167424 19.664896 27.89376 27.89376 0 0 1-19.70176 8.146944h-56.905728a27.89376 27.89376 0 0 1-19.70176-8.142848 27.795456 27.795456 0 0 1-8.167424-19.677184V630.3744h112.644096z m-27.869184 63.73376h-56.905728v55.857152h56.905728v-55.857152z m26.939392-174.98112v55.857152h-55.967744v-55.857152h55.967744zM532.598784 126.976v56.32h56.201216V239.616h-56.209408v52.146176h56.209408v56.32h-56.201216v55.627776h56.201216v55.627776h-56.201216v55.62368H476.16v-55.627776h56.201216V403.705856H476.16v-55.62368h56.201216v-56.32H476.16v-56.32h56.201216V183.296H476.16V126.976h56.438784z M660.48 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M588.8 630.378496v111.24736a27.783168 27.783168 0 0 1-8.167424 19.664896 27.89376 27.89376 0 0 1-19.70176 8.146944h-56.905728a27.89376 27.89376 0 0 1-19.70176-8.142848 27.795456 27.795456 0 0 1-8.167424-19.677184V630.3744h112.644096z m-27.869184 63.73376h-56.905728v55.857152h56.905728v-55.857152z m26.939392-174.98112v55.857152h-55.967744v-55.857152h55.967744zM532.598784 126.976v56.32h56.201216V239.616h-56.209408v52.146176h56.209408v56.32h-56.201216v55.627776h56.201216v55.627776h-56.201216v55.62368H476.16v-55.627776h56.201216V403.705856H476.16v-55.62368h56.201216v-56.32H476.16v-56.32h56.201216V183.296H476.16V126.976h56.438784z M660.48 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
}
.icon-weizhi::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M242.688 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H242.688c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M652.288 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z M421.888 501.76c0-20.48 4.096-36.864 8.192-47.104s12.288-20.48 22.528-28.672c10.24-10.24 20.48-16.384 34.816-20.48 12.288-4.096 24.576-6.144 38.912-6.144 28.672 0 51.2 8.192 71.68 24.576 18.432 16.384 28.672 36.864 28.672 63.488 0 12.288-4.096 22.528-8.192 32.768s-14.336 22.528-30.72 36.864-22.528 24.576-28.672 30.72-8.192 14.336-12.288 22.528-2.048 14.336-2.048 34.816h-40.96c0-20.48 2.048-30.72 4.096-40.96s6.144-18.432 12.288-26.624 12.288-18.432 26.624-32.768 24.576-24.576 28.672-30.72 8.192-16.384 8.192-30.72-6.144-24.576-16.384-34.816-24.576-16.384-43.008-16.384c-40.96 0-61.44 28.672-61.44 69.632h-40.96z m122.88 225.28h-40.96v-40.96h40.96v40.96z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M242.688 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H242.688c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M652.288 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z M421.888 501.76c0-20.48 4.096-36.864 8.192-47.104s12.288-20.48 22.528-28.672c10.24-10.24 20.48-16.384 34.816-20.48 12.288-4.096 24.576-6.144 38.912-6.144 28.672 0 51.2 8.192 71.68 24.576 18.432 16.384 28.672 36.864 28.672 63.488 0 12.288-4.096 22.528-8.192 32.768s-14.336 22.528-30.72 36.864-22.528 24.576-28.672 30.72-8.192 14.336-12.288 22.528-2.048 14.336-2.048 34.816h-40.96c0-20.48 2.048-30.72 4.096-40.96s6.144-18.432 12.288-26.624 12.288-18.432 26.624-32.768 24.576-24.576 28.672-30.72 8.192-16.384 8.192-30.72-6.144-24.576-16.384-34.816-24.576-16.384-43.008-16.384c-40.96 0-61.44 28.672-61.44 69.632h-40.96z m122.88 225.28h-40.96v-40.96h40.96v40.96z'/%3E%3C/svg%3E");
}
+231
View File
@@ -0,0 +1,231 @@
/* 本文件由 scripts/gen-yh-theme.mjs 生成(color-mix 已构建期静态化),勿手改派生色值。
定稿:07-seam-pure.html(浅色接缝条·纯享版);挂接:main.ts 置于 icons.css 之后。
档位开关挂在 <html>:data-theme=blue|ocean|ink|violet|teal × side-deep|side-light × grad-on|grad-off × side-plain */
/* ============ 变量体系(:root 默认档 = blue + side-deep + grad-on + side-follow) ============ */
:root{
--brand:#577afb; --brand-2:#7b8ffd; --brand-dark:#4264e4;
--brand-soft:#eef2ff; --brand-a:rgba(87,122,251,0.34); --glow:rgba(87,122,251,0.42);
--topbar-bg:linear-gradient(100deg,var(--brand) 0%,var(--brand-2) 100%);
--green:#20a162; --green-d:#188451;
--red:#e54d42; --red-soft:#fef1f0;
/* 第一波(P1)沿用现状 56/56,紧凑化 52/44 留 P2(方案 §8-D2) */
--topbar-h:56px; --crumb-h:56px; --side-w:200px; --r:10px; --r-s:7px;
--page-bg:#f3f5fa; --panel:#fff; --line:#e9edf5; --line-2:#f0f3f9;
--text:#1e2635; --text-2:#5a6478; --text-3:#98a1b2;
--sh:0 1px 2px rgba(16,24,40,.04),0 14px 30px -22px rgba(16,24,40,.4);
--side-bg:linear-gradient(180deg,#5e7ffb 0%,#415bbb 100%);
--side-text:#c7d2ea; --side-icon:#95a6cb;
--side-line:rgba(255,255,255,.08);
--side-hover:rgba(255,255,255,.09); --side-hover-text:#fff;
--side-active-bg:rgba(255,255,255,.18); --side-active-text:#fff;
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
--strip-bg:linear-gradient(180deg,#e9eefe 0,#fff 100%);
}
/* blue · brand #577afb */
html[data-theme="blue"]{
--brand:#577afb; --brand-2:#7b8ffd; --brand-dark:#4264e4;
--brand-soft:#eef2ff; --brand-a:rgba(87,122,251,0.34); --glow:rgba(87,122,251,0.42);
--strip-bg:linear-gradient(180deg,#e9eefe 0,#fff 100%);
}
html[data-theme="blue"].side-deep{--side-bg:#425dbf}
html[data-theme="blue"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#5e7ffb 0%,#415bbb 100%)}
html[data-theme="blue"].side-light{--side-bg:#f3f6ff}
html[data-theme="blue"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e9eefe 0%,#f0f3fa 100%)}
html[data-theme="blue"].grad-off{--topbar-bg:#577afb}
/* ocean · brand #2f7ce0 */
html[data-theme="ocean"]{
--brand:#2f7ce0; --brand-2:#4a9bf5; --brand-dark:#2568c4;
--brand-soft:#eaf3fe; --brand-a:rgba(47,124,224,0.34); --glow:rgba(47,124,224,0.42);
--strip-bg:linear-gradient(180deg,#e4eefb 0,#fff 100%);
}
html[data-theme="ocean"].side-deep{--side-bg:#255fab}
html[data-theme="ocean"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#3781e1 0%,#255da8 100%)}
html[data-theme="ocean"].side-light{--side-bg:#f0f6fd}
html[data-theme="ocean"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e4eefb 0%,#eff3f9 100%)}
html[data-theme="ocean"].grad-off{--topbar-bg:#2f7ce0}
/* ink · brand #3d4d63 */
html[data-theme="ink"]{
--brand:#3d4d63; --brand-2:#6d82a1; --brand-dark:#2e3c4f;
--brand-soft:#eef1f6; --brand-a:rgba(61,77,99,0.34); --glow:rgba(61,77,99,0.5);
--strip-bg:linear-gradient(180deg,#e6e8eb 0,#fff 100%);
}
html[data-theme="ink"].side-deep{--side-bg:#303d51}
html[data-theme="ink"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#455469 0%,#2f3c50 100%)}
html[data-theme="ink"].side-light{--side-bg:#f1f3f4}
html[data-theme="ink"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e6e8eb 0%,#eff2f5 100%)}
html[data-theme="ink"].grad-off{--topbar-bg:#3d4d63}
/* violet · brand #6d5ae0 */
html[data-theme="violet"]{
--brand:#6d5ae0; --brand-2:#8f7cf2; --brand-dark:#5a48c9;
--brand-soft:#f1effe; --brand-a:rgba(109,90,224,0.34); --glow:rgba(109,90,224,0.42);
--strip-bg:linear-gradient(180deg,#eceafb 0,#fff 100%);
}
html[data-theme="violet"].side-deep{--side-bg:#5246ab}
html[data-theme="violet"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#7361e1 0%,#5045a8 100%)}
html[data-theme="violet"].side-light{--side-bg:#f5f3fd}
html[data-theme="violet"].side-light.grad-on{--side-bg:linear-gradient(180deg,#eceafb 0%,#f1f2f9 100%)}
html[data-theme="violet"].grad-off{--topbar-bg:#6d5ae0}
/* teal · brand #0d9488 */
html[data-theme="teal"]{
--brand:#0d9488; --brand-2:#18b5a5; --brand-dark:#0b7c72;
--brand-soft:#e8f7f5; --brand-a:rgba(13,148,136,0.36); --glow:rgba(13,148,136,0.42);
--strip-bg:linear-gradient(180deg,#e0f1f0 0,#fff 100%);
}
html[data-theme="teal"].side-deep{--side-bg:#0d706c}
html[data-theme="teal"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#17988d 0%,#0d6e6a 100%)}
html[data-theme="teal"].side-light{--side-bg:#eef8f7}
html[data-theme="teal"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e0f1f0 0%,#eef4f7 100%)}
html[data-theme="teal"].grad-off{--topbar-bg:#0d9488}
/* ---- 深浅档非底色 token(与品牌色联动的用 var 派生;浅色档 line/hover/active 用默认档实测值,
主题间差异肉眼不可辨,保持单档以压复杂度——同定稿 side-light 组) ---- */
html.side-light{
--side-text:#4a5568; --side-icon:#98a2b3;
--side-line:#ccd6f2;
--side-hover:#e3e8f8; --side-hover-text:#1e2635;
--side-active-bg:#e6ebfe; --side-active-text:var(--brand-dark);
--side-bar:var(--brand); --side-scroll:#d3d9e3;
}
html.grad-off{--topbar-bg:var(--brand)}
/* ---- 侧栏与品牌色解耦(side-follow 关) ---- */
html.side-plain.side-deep{
--side-bg:#2c3444; --side-text:#c2cbd9; --side-icon:#909dae;
--side-line:rgba(255,255,255,.08);
--side-hover:rgba(255,255,255,.08); --side-hover-text:#fff;
--side-active-bg:rgba(255,255,255,.16); --side-active-text:#fff;
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
}
html.side-plain.side-light{
--side-bg:#f7f8fa; --side-text:#4a5568; --side-icon:#98a2b3;
--side-line:#e6eaf0; --side-hover:#eff1f5;
--side-active-bg:#eceef3; --side-active-text:#2f3a4c;
--side-bar:#8b95a8; --side-scroll:#d3d9e3;
}
html.side-plain.grad-on.side-deep{--side-bg:linear-gradient(180deg,#333c4d 0%,#272e3c 100%)}
html.side-plain.grad-on.side-light{--side-bg:linear-gradient(180deg,#fafbfc 0%,#f2f4f7 100%)}
html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
/* ---- 顶栏:.header-container ---- */
.layout-wrap .header-container{background:var(--topbar-bg);box-shadow:0 1px 3px rgba(16,24,40,.14);transition:background .28s}
.layout-wrap .header-container h4{font-weight:600;letter-spacing:.4px}
.layout-wrap .header-container .delimiter{background:rgba(255,255,255,.28)}
/* 顶栏 logo 徽标(模板新增节点) */
.logo-badge{width:30px;height:30px;flex:0 0 auto;border-radius:9px;background:#fff;display:grid;place-items:center;box-shadow:0 2px 7px rgba(0,0,0,.16);margin-right:10px}
.logo-badge svg,.logo-badge img{width:22px;height:22px;display:block}
/* 用户胶囊 */
.layout-wrap .header-aside{padding:0 8px 0 5px;height:36px;border-radius:999px;transition:background .16s}
.layout-wrap .header-aside:hover{background:rgba(255,255,255,.16)}
/* ---- 侧栏:.left-menu(皮肤覆盖 layout.css 浅色档;深浅由 --side-* 驱动) ---- */
.layout-wrap .left-menu{background:var(--side-bg);border-right:1px solid var(--side-line);color:var(--side-text);transition:background .28s}
.layout-wrap .left-menu::-webkit-scrollbar{width:8px}
.layout-wrap .left-menu::-webkit-scrollbar-thumb{background:var(--side-scroll);border-radius:8px}
.layout-wrap .left-menu ul{padding:8px 0 18px}
.layout-wrap .left-menu li{margin:1px 8px;border-radius:var(--r-s);transition:background .15s,color .15s}
.layout-wrap .left-menu li a{
display:block;height:40px;line-height:40px;padding:0 12px;border-radius:var(--r-s);
color:var(--side-text);font-size:13px;border-right:none;transition:background .15s,color .15s;position:relative;
}
.layout-wrap .left-menu li a:hover{background:var(--side-hover);color:var(--side-hover-text)}
.layout-wrap .left-menu li a:hover .iconfont,.layout-wrap .left-menu li:hover .iconfont{color:var(--side-hover-text)}
.layout-wrap .left-menu li.active{background:var(--side-active-bg);border-right:0}
.layout-wrap .left-menu li.active a{color:var(--side-active-text);font-weight:600;background:transparent;border-right:none}
.layout-wrap .left-menu li.active a .iconfont{color:var(--side-active-text)}
.layout-wrap .left-menu li.active::before{
content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);
width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--side-bar);
}
.layout-wrap .left-menu li.child-item{margin:0 8px}
.layout-wrap .left-menu li.child-item a{height:36px;line-height:36px;font-size:12.5px}
.layout-wrap .left-menu li.child-item.active::before{height:16px}
/* 菜单图标 mask 类(icons.css 用 background-color 上色,这里给品牌感近色) */
.layout-wrap .left-menu .iconfont{margin-right:8px}
.layout-wrap .left-menu .arrow-up,.layout-wrap .left-menu .arrow-down{border-color:var(--side-icon)}
/* ---- 面包屑:.path-nav → 浅色接缝条(方案 §7.3 融合站点身份带) ---- */
.layout-wrap .path-nav{
height:var(--crumb-h);background:var(--strip-bg);border-bottom:1px solid var(--line);
color:var(--text-3);box-sizing:border-box;
}
.layout-wrap .path-nav .ivu-breadcrumb-item-link{color:var(--text-2)}
.layout-wrap .path-nav .ivu-breadcrumb>span:last-child .ivu-breadcrumb-item-link,.layout-wrap .path-nav .ivu-breadcrumb>span:last-child{color:var(--text);font-weight:600}
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变。
v3:站点身份已由页头「学校名.站点名」承担,原先 band-sub 让位身份橙的覆盖已删,
否则子站态上面包屑顶部常驻一条橙色横杠,与页头身份重复,且被误读成边框。 */
.layout-wrap .path-nav::before{
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
}
.layout-wrap .path-nav::after{display:none}
/* ---- 内容区:灰底 + 白卡 ---- */
.base-wrap-main{background:var(--page-bg)}
.base-wrap-main .main-container{
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
box-shadow:var(--sh);margin:0 18px 24px;padding:16px 20px 24px;box-sizing:border-box;
}
/* 栏目工作台左树卡 */
.base-wrap-main .tree-container{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh)}
/* ---- 搜索区分区化(卡片内 filterbar 语义) ---- */
.base-wrap-main .search-wrap{
background:#fbfcfe;border:1px solid var(--line-2);border-radius:var(--r-s);
padding:10px 12px;height:auto;min-height:52px;box-sizing:border-box;margin-bottom:12px;
}
/* ---- 表格/操作列皮肤(三蓝归一 #5979EA/#2d8cf0 → var(--brand)) ---- */
.ivu-table-header th,.ivu-table-fixed-header th{background:#f7f9fc !important;color:var(--text-2)}
.ivu-table td{border-bottom:1px solid var(--line-2)}
.ivu-table-body tr:hover td,.ivu-table tbody tr:hover td{background:var(--brand-soft) !important}
.base-wrap-main .ivu-table .operating-link{color:var(--brand)}
.base-wrap-main .ivu-table .operating-link.disabled{color:#aaa}
.ivu-table-cell .operating-btn:hover{background:var(--brand);color:#fff}
/* ---- view-ui-plus 品牌态 ---- */
.ivu-btn-primary{background-color:var(--brand);border-color:var(--brand)}
.ivu-btn-primary:hover,.ivu-btn-primary.active{background-color:var(--brand-dark);border-color:var(--brand-dark)}
.ivu-btn-success{background-color:var(--green);border-color:var(--green)}
.ivu-btn-success:hover,.ivu-btn-success.active{background-color:var(--green-d);border-color:var(--green-d)}
.ivu-btn-error{background-color:var(--red);border-color:var(--red)}
.ivu-btn-error:hover,.ivu-btn-error.active{background-color:#c93f36;border-color:#c93f36}
.ivu-btn-text{color:var(--brand)}
.ivu-btn-text:hover{color:var(--brand-dark)}
.ivu-tabs-ink-bar{background-color:var(--brand)}
.ivu-tabs-tab:hover,.ivu-tabs-tab-active{color:var(--brand)}
.base-wrap-main .ivu-page-item-active{background:var(--brand);border-color:var(--brand)}
.base-wrap-main .ivu-page-item-active a,.base-wrap-main .ivu-page-item-active a:hover{color:#fff !important}
.base-wrap-main .ivu-page-item:hover{border-color:var(--brand)}
.base-wrap-main .ivu-page-item:hover a,.base-wrap-main .ivu-page-item:hover i{color:var(--brand)}
.base-wrap-main .ivu-page-prev:hover i,.base-wrap-main .ivu-page-next:hover i{border-color:var(--brand);color:var(--brand)}
.ivu-select-item-selected{color:var(--brand)}
.ivu-select-item-selected:hover{background:var(--brand-soft)}
.ivu-checkbox-checked .ivu-checkbox-inner{border-color:var(--brand);background-color:var(--brand)}
.ivu-radio-checked .ivu-radio-inner{border-color:var(--brand);background-color:var(--brand)}
.ivu-checkbox-wrapper:hover .ivu-checkbox-inner,.ivu-radio-wrapper:hover .ivu-radio-inner{border-color:var(--brand)}
.ivu-input:focus,.ivu-input-focused,.ivu-select-visible .ivu-select-selection{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand-a)}
.ivu-input:hover,.ivu-select-selection:hover{border-color:#c6d0e2}
.ivu-switch-checked{border-color:var(--brand);background-color:var(--brand)}
.ivu-tree-title:hover{background-color:var(--brand-soft)}
.ivu-tree-title-selected,a.ivu-tree-title-selected:hover{background-color:var(--brand-soft);color:var(--brand)}
.ivu-spin-dot{background-color:var(--brand)}
.ivu-modal-header-inner{font-weight:600;color:var(--text)}
a{color:var(--brand)}
a:hover{color:var(--brand-dark)}
/* ---- 滚动条(定稿全局) ---- */
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-thumb{background:#c9d0dd;border-radius:8px}
::-webkit-scrollbar-thumb:hover{background:#aeb8c9}
::-webkit-scrollbar-track{background:transparent}
/* ---- 工作台/主题管理全屏页深色顶栏吃主题变量(方案 §7.3) ---- */
.wb-header{background:var(--topbar-bg) !important}
+127
View File
@@ -0,0 +1,127 @@
<template>
<Modal v-model="visible" title="批量添加栏目" :width="520" :mask-closable="false">
<div class="batch-body">
<Form :label-width="90">
<Form-Item label="上级栏目:">
<span class="batch-val">{{ parentName || '根栏目' }}</span>
</Form-Item>
<Form-Item label="栏目类型:">
<RadioGroup v-model="typeValue">
<!-- 外链必须逐个填地址,不提供批量 -->
<Radio :label="1">文章列表</Radio>
<Radio :label="2">单页</Radio>
</RadioGroup>
</Form-Item>
<Form-Item label="栏目名称:">
<Input v-model="rawNames" type="textarea" :rows="8" placeholder="一行一个栏目名称,保存时按行顺序写入排序号" />
</Form-Item>
</Form>
<p class="batch-tip">将按行逐个创建栏目;某行失败不影响其余行,结束后统一提示结果。</p>
</div>
<template #footer>
<Button type="primary" :loading="loading" @click="submit">确认</Button>
<Button @click="visible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
/* UserBase BatchAddGroup 同款交互:textarea 一行一个名称批量建组;
CMS 无后端批量接口,前端循环调 categoryApi.save(用户约定方案),按行序写 sortNo=1..n。 */
import { ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
/** 上级栏目 id(0=根;来自当前选中节点,虚拟根 id=0 天然合法) */
defaultPid?: number | null
/** 上级栏目名称(展示用) */
parentName?: string
defaultType?: number | null
}>()
const emit = defineEmits<{ success: [lastSavedId?: number] }>()
const store = useCmsStore();
const loading = ref(false)
const rawNames = ref('')
const typeValue = ref<number>(1)
function normalizeNames() {
return rawNames.value
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean);
}
async function submit() {
if (!store.siteId) {
Message.warning('请先选择站点');
return;
}
const names = normalizeNames();
if (!names.length) {
Message.warning('请输入栏目名称');
return;
}
const parentId = props.defaultPid ?? 0;
loading.value = true;
const failed: string[] = [];
let lastSavedId: number | undefined;
try {
for (let i = 0; i < names.length; i++) {
const name = names[i];
try {
const saved = await categoryApi
.save({
siteId: store.siteId,
parentId,
name,
type: typeValue.value,
sortNo: i + 1,
status: 1,
})
.send();
lastSavedId = saved?.id ?? lastSavedId;
} catch (e: any) {
failed.push(name);
console.warn(`栏目「${name}」创建失败`, e?.message);
}
}
if (failed.length) {
Message.warning(`创建完成:成功 ${names.length - failed.length} 条,失败 ${failed.length} 条(${failed.join('、')})`);
} else {
Message.success(`成功创建 ${names.length} 个栏目!`);
}
emit('success', lastSavedId);
visible.value = false;
} finally {
loading.value = false;
}
}
watch(visible, (v) => {
if (v) {
rawNames.value = '';
typeValue.value = props.defaultType === 2 ? 2 : 1;
}
})
</script>
<style lang="scss" scoped>
.batch-body {
.batch-val {
color: #333;
font-weight: 600;
}
.batch-tip {
color: #999;
font-size: 12px;
margin: 0 0 8px 12px;
}
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
/**
* 区块类型缩略示意图(docs/20 F2 / R-40):添加抽屉类型卡 + 属性面板形态 RadioCard 共用。
* 纯内联 SVG 线框示意(本地资产,无外部 CDN——皮肤包契约红线仅约束门户,管理端同样不引);
* 每种 type/variant 一幅极简骨架图,新组件类型 = 在 THUMBS 补一支绘制函数(注册表驱动配套)。
*/
const props = withDefaults(
defineProps<{
/** 区块类型(BLOCK_TYPES.type) */
type: string;
/** article_list 展示形态(style),其余类型忽略 */
variant?: string;
}>(),
{ variant: "" },
);
/** 图底色板(示意线框用,与皮肤无关) */
const C = {
band: "#dce6f5",
block: "#e8eef8",
strong: "#9db8e0",
text: "#c3cfe2",
accent: "#5b8dd9",
};
</script>
<template>
<svg class="block-type-thumb" viewBox="0 0 120 64" preserveAspectRatio="xMidYMid meet" role="img" aria-hidden="true">
<!-- article_list 五形态 -->
<template v-if="type === 'article_list'">
<!-- card:头条图 + 摘要 + 行列表 -->
<template v-if="variant === 'card' || !variant">
<rect x="6" y="6" width="34" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="45" y="9" width="44" height="5" rx="1.5" :fill="C.strong" />
<rect x="45" y="18" width="66" height="3" rx="1.5" :fill="C.text" />
<rect x="45" y="24" width="56" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="34" width="108" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="50" width="104" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="58" width="92" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- news_grid:色带底 + 双栏行 -->
<template v-else-if="variant === 'news_grid'">
<rect x="0" y="0" width="120" height="64" :fill="C.band" />
<rect x="8" y="8" width="104" height="48" rx="2" fill="#ffffff" />
<rect x="14" y="14" width="40" height="4" rx="2" :fill="C.accent" />
<rect x="14" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
<rect x="14" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
<rect x="14" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- media_focus:来源徽标 + 行 -->
<template v-else-if="variant === 'media_focus'">
<rect x="6" y="8" width="24" height="8" rx="2" :fill="C.accent" />
<rect x="36" y="11" width="60" height="3" rx="1.5" :fill="C.strong" />
<rect x="6" y="24" width="20" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="32" y="27" width="70" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="40" width="18" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="30" y="43" width="64" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="54" width="34" height="4" rx="1.5" :fill="C.band" />
</template>
<!-- compact:纯文字行 -->
<template v-else-if="variant === 'compact'">
<rect x="8" y="10" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="10" width="84" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="21" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="21" width="76" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="32" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="32" width="88" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="43" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="43" width="72" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="54" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="54" width="80" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- image_wall:图片格阵 -->
<template v-else-if="variant === 'image_wall'">
<rect x="6" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="35" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="64" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="93" y="8" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="6" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="35" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="64" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="93" y="34" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
</template>
<!-- ticker:横条 + 滚动标题 -->
<template v-else-if="type === 'ticker'">
<rect x="0" y="20" width="120" height="24" :fill="C.accent" />
<rect x="6" y="28" width="26" height="8" rx="2" fill="#ffffff" opacity="0.9" />
<rect x="38" y="30" width="34" height="4" rx="2" fill="#ffffff" opacity="0.75" />
<rect x="78" y="30" width="30" height="4" rx="2" fill="#ffffff" opacity="0.6" />
</template>
<!-- gallery:居中标题 + 图墙 -->
<template v-else-if="type === 'gallery'">
<rect x="46" y="4" width="28" height="5" rx="2" :fill="C.strong" />
<rect x="38" y="11" width="44" height="2.5" rx="1.25" :fill="C.text" />
<rect x="8" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="20" width="19" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="8" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="44" width="19" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- links:胶囊按钮行 -->
<template v-else-if="type === 'links'">
<rect x="46" y="6" width="28" height="5" rx="2" :fill="C.strong" />
<rect x="10" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="38" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="94" y="22" width="18" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="24" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="52" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="80" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- quick_links:图标宫格 -->
<template v-else-if="type === 'quick_links'">
<circle cx="26" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="26" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- notice_bar:通栏提示条 -->
<template v-else-if="type === 'notice_bar'">
<rect x="0" y="22" width="120" height="20" :fill="C.band" />
<rect x="0" y="22" width="120" height="1" :fill="C.strong" />
<rect x="0" y="41" width="120" height="1" :fill="C.strong" />
<rect x="8" y="29" width="20" height="6" rx="2" :fill="C.accent" />
<rect x="34" y="31" width="70" height="3" rx="1.5" :fill="C.strong" />
</template>
<!-- D 系新组件(docs/20 D-1~D-9):D 步上线时同步补绘 -->
<!-- D-1 stats_band:大数字横排 -->
<template v-else-if="type === 'stats_band'">
<rect x="0" y="10" width="120" height="44" :fill="C.band" />
<text x="24" y="34" font-size="13" font-weight="700" :fill="C.accent">3.2万</text>
<rect x="20" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
<text x="60" y="34" font-size="13" font-weight="700" :fill="C.accent">2800</text>
<rect x="56" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
<text x="94" y="34" font-size="13" font-weight="700" :fill="C.accent">12</text>
<rect x="90" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-2 icon_nav:六边形图标阵 -->
<template v-else-if="type === 'icon_nav'">
<polygon points="26,6 38,13 38,27 26,34 14,27 14,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="16" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="60,6 72,13 72,27 60,34 48,27 48,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="50" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="94,6 106,13 106,27 94,34 82,27 82,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="26,46 38,53 38,67 26,74 14,67 14,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="16" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="60,46 72,53 72,67 60,74 48,67 48,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="50" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="94,46 106,53 106,67 94,74 82,67 82,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-3 featured_topics:专题大卡 -->
<template v-else-if="type === 'featured_topics'">
<rect x="6" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="12" y="40" width="30" height="4" rx="2" fill="#ffffff" />
<rect x="12" y="48" width="18" height="4" rx="2" :fill="C.accent" />
<rect x="62" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="68" y="40" width="30" height="4" rx="2" fill="#ffffff" />
<rect x="68" y="48" width="18" height="4" rx="2" :fill="C.accent" />
</template>
<!-- D-5 video_tour:视频封面 + 播放键 -->
<template v-else-if="type === 'video_tour'">
<rect x="6" y="8" width="108" height="48" rx="3" :fill="C.band" />
<circle cx="60" cy="32" r="12" fill="#ffffff" opacity="0.9" />
<polygon points="56,25 70,32 56,39" :fill="C.accent" />
</template>
<!-- D-6 events:大日期块 + 活动列表 -->
<template v-else-if="type === 'events'">
<rect x="6" y="8" width="26" height="26" rx="2" :fill="C.accent" />
<rect x="10" y="13" width="18" height="5" rx="1.5" fill="#ffffff" opacity="0.9" />
<rect x="38" y="12" width="50" height="4" rx="2" :fill="C.strong" />
<rect x="38" y="21" width="66" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="40" width="26" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="38" y="44" width="50" height="4" rx="2" :fill="C.strong" />
<rect x="38" y="53" width="66" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-7 people:圆头像卡 -->
<template v-else-if="type === 'people'">
<circle cx="24" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="12" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="14" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="48" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="50" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
<circle cx="96" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="86" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-8 campus_wall:瀑布流(错落图) -->
<template v-else-if="type === 'campus_wall'">
<rect x="8" y="6" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="6" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="6" width="24" height="32" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="6" width="19" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="8" y="40" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="30" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="44" width="24" height="14" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="34" width="19" height="24" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- D-9 badges:徽章横排 -->
<template v-else-if="type === 'badges'">
<circle cx="26" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- 兜底:区块头 + 行 -->
<template v-else>
<rect x="8" y="8" width="32" height="5" rx="2" :fill="C.strong" />
<rect x="8" y="22" width="104" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="32" width="96" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="52" width="88" height="3" rx="1.5" :fill="C.text" />
</template>
</svg>
</template>
<style scoped lang="scss">
.block-type-thumb {
display: block;
width: 100%;
height: 100%;
background: #fbfcfe;
border-radius: 4px;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { assetUrl } from "@/api/module/cms";
/**
* F5(docs/20)页脚结构示意图:页脚 Tab 内的固定示意图,标注各字段在门户页脚中的视觉位置——
* 品牌区(站名+联系方式+多校区地址行)/ 二维码区(微信+微博,4:3)/ 社媒图标行 / 版权备案行。
* 已填字段实时回显内容(填完即知页脚长什么样);空字段以灰字占位标注。
*/
const props = defineProps<{
contact?: string;
footerAddresses?: string;
qrWechat?: string;
qrWeibo?: string;
socials?: { label: string; value?: string }[];
copyright?: string;
beian?: string;
}>();
const addressLines = (props.footerAddresses || "")
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean)
.slice(0, 3);
</script>
<template>
<div class="footer-preview">
<div class="fp-caption">页脚结构示意(下图与字段一一对应,已填内容实时回显)</div>
<div class="fp-frame" role="img" aria-label="页脚结构示意图">
<div class="fp-brand">
<div class="fp-logo">
<span class="fp-logo-mark">LOGO</span>
<span class="fp-site">XX学校</span>
</div>
<p class="fp-line" :class="{ filled: contact }">
{{ contact || "① 联系方式(页脚品牌区)" }}
</p>
<p v-for="(l, i) in addressLines" :key="i" class="fp-line small filled">{{ l }}</p>
<p v-if="!addressLines.length" class="fp-line small">② 多校区地址(每行一个校区)</p>
</div>
<div class="fp-qr">
<div class="fp-qr-box">
<img v-if="qrWechat" :src="assetUrl(qrWechat)" alt="微信二维码预览" />
<span v-else>微信二维码<br />③ 4:3</span>
</div>
<div class="fp-qr-box">
<img v-if="qrWeibo" :src="assetUrl(qrWeibo)" alt="微博二维码预览" />
<span v-else>微博二维码<br />③ 4:3</span>
</div>
</div>
<div class="fp-social">
<span class="fp-social-label">④ 社媒图标行:</span>
<span v-for="s in socials" :key="s.label" class="fp-social-item" :class="{ on: s.value }">
{{ s.label }}
</span>
</div>
<div class="fp-bottom">
<span :class="{ filled: copyright }">{{ copyright || "⑤ 版权文字" }}</span>
<span :class="{ filled: beian }" class="fp-beian">{{ beian || "⑥ 备案号" }}</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.footer-preview {
margin-top: 6px;
.fp-caption {
color: #8a93a6;
font-size: 12px;
margin-bottom: 8px;
}
.fp-frame {
border: 1px solid #e9edf5;
border-radius: 8px;
background: linear-gradient(180deg, #1f3a5f 0%, #16294a 100%);
color: rgba(255, 255, 255, 0.5);
padding: 18px 22px 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px 18px;
font-size: 12px;
line-height: 1.7;
.fp-brand {
.fp-logo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.fp-logo-mark {
border: 1px dashed rgba(255, 255, 255, 0.35);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
}
.fp-site {
color: rgba(255, 255, 255, 0.75);
font-weight: 600;
}
}
.fp-line {
margin: 2px 0;
&.small {
font-size: 11px;
opacity: 0.9;
}
&.filled {
color: #fff;
}
}
}
.fp-qr {
display: flex;
gap: 12px;
align-items: flex-start;
.fp-qr-box {
width: 84px;
height: 63px;
border: 1px dashed rgba(255, 255, 255, 0.4);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 10px;
position: relative;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
}
.fp-social {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.fp-social-label {
color: rgba(255, 255, 255, 0.45);
}
.fp-social-item {
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 1px 10px;
font-size: 11px;
opacity: 0.55;
&.on {
opacity: 1;
border-color: rgba(255, 255, 255, 0.85);
color: #fff;
}
}
}
.fp-bottom {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding-top: 8px;
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 11px;
.filled {
color: rgba(255, 255, 255, 0.85);
}
}
}
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* D-2(docs/20)内置图标选择器:图标网格预览 + 点选即绑定图标标识符(§3.3 定案 2 综合方案 A+C)。
* 数据源 icons.ts(与门户骨架 fragment 同 key);再次点击已选图标 = 清除(回 null = 首字徽章兜底)。
*/
import { ICON_DEFS } from "@/views/cms/homeLayout/icons";
const model = defineModel<string | null>("modelValue", { default: null });
function pick(key: string) {
model.value = model.value === key ? null : key;
}
</script>
<template>
<div class="icon-grid">
<button
v-for="icon in ICON_DEFS"
:key="icon.key"
type="button"
class="icon-cell"
:class="{ active: model === icon.key }"
:title="`${icon.label}(${icon.key})`"
@click="pick(icon.key)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path v-for="(d, i) in icon.paths" :key="i" :d="d" />
</svg>
<span class="icon-name">{{ icon.label }}</span>
</button>
<p class="icon-tip">未选图标时入口渲染「名称首字徽章」;再次点击已选图标可清除</p>
</div>
</template>
<style scoped lang="scss">
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
.icon-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
border: 1px solid #e2e8f2;
border-radius: 6px;
background: #fff;
padding: 7px 2px 5px;
cursor: pointer;
color: #515a6e;
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #5cadff;
color: #2d8cf0;
}
&.active {
border-color: #2d8cf0;
color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
}
svg {
width: 22px;
height: 22px;
}
.icon-name {
font-size: 11px;
line-height: 1.2;
}
}
.icon-tip {
grid-column: 1 / -1;
color: #999;
font-size: 12px;
margin: 4px 0 0;
}
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Message } from "view-ui-plus";
import { assetUrl, uploadUrl } from "@/api/module/cms";
/**
* 图片类配置字段控件(docs/16 D7):上传 + 手动粘贴路径双通道。
* v-model 绑定资产路径(/ 开头相对路径或 http(s) URL);上传成功自动回填(服务端返回相对 url)。
* 缩略预览 + 清空;格式校验由外层 FormItem 规则(ASSET_RE)承担,控件只负责取值。
*/
const props = defineProps<{
modelValue: string;
/** 上传归属站点(附件场景引用;缺省不携带) */
siteId?: number;
/** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */
scene?: string;
placeholder?: string;
/** F5(docs/20):预览/占位框比例("1:1" favicon / "4:3" 二维码;缺省 8:5 通用横图),
* 无值时按比例显示虚线占位框,帮助确认最终在门户中的尺寸感 */
aspect?: "1:1" | "4:3";
}>();
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
const manualOpen = ref(false);
const manualPath = ref("");
const uploading = ref(false);
const previewSrc = computed(() => (props.modelValue ? assetUrl(props.modelValue) : ""));
const action = computed(() => {
const base = uploadUrl();
const params: string[] = [];
if (props.siteId) params.push(`siteId=${props.siteId}`);
if (props.scene) params.push(`scene=${props.scene}`);
return params.length ? `${base}?${params.join("&")}` : base;
});
function onUploaded(res: any) {
uploading.value = false;
if (res && res.code === 0 && res.data && res.data.url) {
emit("update:modelValue", res.data.url);
manualOpen.value = false;
} else {
Message.error(res?.msg || "图片上传失败");
}
}
function onUploadError() {
uploading.value = false;
Message.error("图片上传失败");
}
function confirmManual() {
const v = manualPath.value.trim();
if (!v) {
Message.warning("请先粘贴图片路径");
return;
}
emit("update:modelValue", v);
manualPath.value = "";
manualOpen.value = false;
}
function clearValue() {
emit("update:modelValue", "");
}
</script>
<template>
<div class="image-asset-field">
<div class="iaf-row">
<Upload
v-if="!modelValue"
:action="action"
:show-upload-list="false"
:on-success="onUploaded"
:on-error="onUploadError"
accept="image/*"
>
<Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button>
</Upload>
<span v-else class="iaf-thumb" :class="aspect ? 'iaf-thumb-' + aspect.replace(':', 'x') : ''">
<img :src="previewSrc" alt="" />
<span class="iaf-thumb-del" title="清除" @click="clearValue">
<Icon type="ios-close" />
</span>
</span>
<!-- F5:无值时按字段比例显示虚线占位框(尺寸感示意) -->
<span v-if="!modelValue && aspect" class="iaf-holder" :class="'iaf-holder-' + aspect!.replace(':', 'x')">
{{ aspect }} 位
</span>
<Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen">
{{ manualOpen ? "收起" : "手动粘贴路径" }}
</Button>
</div>
<Input
v-if="manualOpen"
v-model="manualPath"
size="small"
class="iaf-manual"
:placeholder="placeholder || '粘贴已上传图片的路径,如 /yschcms/upload/…/logo.png'"
>
<Button slot="append" size="small" type="primary" @click="confirmManual">填入</Button>
</Input>
<p v-else-if="modelValue" class="iaf-path">{{ modelValue }}</p>
</div>
</template>
<style scoped lang="scss">
.image-asset-field {
.iaf-row {
display: flex;
align-items: center;
gap: 8px;
}
.iaf-toggle {
color: #999;
&:hover {
color: #2d8cf0;
}
}
.iaf-thumb {
position: relative;
display: inline-flex;
img {
width: 64px;
height: 40px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #dcdee2;
background: #f8f8f9;
}
&.iaf-thumb-1x1 img {
width: 48px;
height: 48px;
object-fit: contain;
}
&.iaf-thumb-4x3 img {
width: 88px;
height: 66px;
object-fit: contain;
}
.iaf-thumb-del {
position: absolute;
top: -7px;
right: -7px;
width: 16px;
height: 16px;
line-height: 14px;
text-align: center;
border-radius: 50%;
background: #ed4014;
color: #fff;
cursor: pointer;
font-size: 12px;
display: none;
}
&:hover .iaf-thumb-del {
display: block;
}
}
.iaf-holder {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed #cfd8e6;
border-radius: 4px;
color: #b4bfce;
font-size: 11px;
background: #fafbfd;
&.iaf-holder-1x1 {
width: 48px;
height: 48px;
}
&.iaf-holder-4x3 {
width: 88px;
height: 66px;
}
}
.iaf-manual {
max-width: 420px;
margin-top: 6px;
}
.iaf-path {
color: #999;
font-size: 12px;
margin-top: 2px;
word-break: break-all;
line-height: 1.5;
}
}
</style>
+145
View File
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
/**
* RichTextEditor 组件行为(审计批次 6:ArticleEdit 复用后,正文上传契约成为两个页面共用底座,
* 需要独立护栏)。覆盖:
* ① 上传 FormData 参数:siteId / scene / articleId 按传入拼装,未传的键不出现;
* ② 上传成功 → emit uploaded(附件 id) 且 insertFn 收到 (url, fileName, url);
* ③ 上传失败(code≠0)→ 不 emit uploaded;
* ④ 工具栏配置仍排除视频组与全屏(Notice 趟坑口径)。
*
* 无 @vue/test-utils 依赖:沿用项目既有 createApp 手写挂载 + vi.mock 桩范式。
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h } from "vue";
const confState = vi.hoisted(() => ({
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
}));
vi.mock("@/assets/config/host.config", () => ({ default: confState.conf }));
/** wangEditor 桩:把传进来的 defaultConfig 挂到全局,测试借此直接驱动 customUpload */
vi.mock("@wangeditor/editor-for-vue", () => ({
Toolbar: defineComponent({
props: ["editor", "defaultConfig", "mode"],
setup(props) {
(globalThis as any).__toolbarConfig = props.defaultConfig;
return () => h("div", { class: "toolbar-stub" });
},
}),
Editor: defineComponent({
props: ["modelValue", "defaultConfig", "mode"],
emits: ["onCreated", "update:modelValue"],
setup(props, { emit }) {
(globalThis as any).__editorConfig = props.defaultConfig;
(globalThis as any).__editorEmitUpdate = (v: string) => emit("update:modelValue", v);
(globalThis as any).__editorCreated = emit;
return () => h("div", { class: "editor-stub" });
},
}),
}));
import RichTextEditor from "@/components/RichTextEditor.vue";
let cleanups: (() => void)[] = [];
function mountEditor(props: Record<string, unknown>, onUploaded?: (id: number) => void) {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp({
render: () => h(RichTextEditor, { modelValue: "", ...props, onUploaded }),
});
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
return el;
}
/** 直接调用组件内部构造的 customUpload(等同 wangEditor 选中图片后的回调) */
async function runUpload(file: File): Promise<{ inserted: string[] }> {
const inserted: string[] = [];
const cfg = (globalThis as any).__editorConfig;
await cfg.MENU_CONF.uploadImage.customUpload(file, (url: string) => inserted.push(url));
return { inserted };
}
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
vi.unstubAllGlobals();
delete (globalThis as any).__editorConfig;
delete (globalThis as any).__toolbarConfig;
});
describe("RichTextEditor - 正文上传与事件契约", () => {
it("① 上传 FormData 带 siteId/scene/articleId(scene 透传契约)", async () => {
const seen: FormData[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: any) => {
seen.push(init.body as FormData);
return { json: async () => ({ code: 0, data: { id: 77, url: "/cms/upload/a.png", fileName: "a.png" } }) };
})
);
mountEditor({ siteId: 5, scene: "svc_item_asset" }, () => {});
await runUpload(new File(["x"], "a.png", { type: "image/png" }));
expect(seen).toHaveLength(1);
expect(seen[0].get("file")).toBeInstanceOf(File);
expect(seen[0].get("siteId")).toBe("5");
expect(seen[0].get("scene")).toBe("svc_item_asset");
expect(seen[0].get("articleId")).toBeNull();
});
it("①b 文章页口径:传 articleId 时随之提交(编辑已有文章)", async () => {
const seen: FormData[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: any) => {
seen.push(init.body as FormData);
return { json: async () => ({ code: 0, data: { id: 78, url: "/cms/upload/b.png", fileName: "b.png" } }) };
})
);
mountEditor({ siteId: 5, articleId: 12 }, () => {});
await runUpload(new File(["x"], "b.png", { type: "image/png" }));
expect(seen[0].get("siteId")).toBe("5");
expect(seen[0].get("articleId")).toBe("12");
expect(seen[0].get("scene")).toBeNull();
});
it("② 上传成功:emit uploaded(id) 且 insertFn 收到 (url, fileName, url)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
json: async () => ({ code: 0, data: { id: 99, url: "/cms/upload/c.png", fileName: "c.png" } }),
}))
);
const ids: number[] = [];
mountEditor({ siteId: 5, scene: "svc_item_asset" }, id => ids.push(id));
const { inserted } = await runUpload(new File(["x"], "c.png", { type: "image/png" }));
expect(ids).toEqual([99]);
expect(inserted).toEqual(["/cms/upload/c.png"]);
});
it("③ 上传失败(code≠0):不 emit uploaded、不插入图片", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => ({ code: 2, msg: "scene 非法" }) }))
);
const ids: number[] = [];
mountEditor({ siteId: 5, scene: "bad_scene" }, id => ids.push(id));
const { inserted } = await runUpload(new File(["x"], "d.png", { type: "image/png" }));
expect(ids).toEqual([]);
expect(inserted).toEqual([]);
});
it("④ 工具栏仍排除视频组与全屏(Notice 趟坑口径不回退)", () => {
mountEditor({ siteId: 5 }, () => {});
expect((globalThis as any).__toolbarConfig.excludeKeys).toEqual(["group-video", "fullScreen"]);
});
});
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import config from "@/assets/config/host.config";
/**
* 通用富文本编辑器(事项库 C-5:从 ArticleEdit 内联 wangEditor 抽取的公共组件;
* 审计批次 6 起 ArticleEdit 亦复用本组件,消除两份实现)。
* v-model 绑定 HTML;图片 customUpload 直传 /api/attachment/upload(fetch + credentials),
* 上传成功 emit uploaded(attachmentId) 供调用方收集(保存时绑定归属)。
* 销毁时 editor.destroy()(wangEditor 必须显式销毁防内存泄漏)。
*/
const props = defineProps<{
modelValue: string;
siteId?: number;
/** 附件场景(article_asset 正文资产等;空=不带 scene;svc 场景已迁办事大厅) */
scene?: string;
/** 文章 id(文章编辑器上传时的归属参数;无文章 id 的页面不传) */
articleId?: number;
placeholder?: string;
/** 建议 ≥320(长文显示不全修复口径:容器随内容增高,短文铺满 min-height) */
height?: number;
/** slate 内容区最小高度(CSS 值);缺省用事项页口径,文章页传入自己的口径保持原观感 */
slateMinHeight?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: string): void;
(e: "uploaded", attachmentId: number): void;
}>();
const editorRef = shallowRef();
const serviceBase = config.isMock ? "" : config.serviceHost;
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
const editorConfig = {
placeholder: props.placeholder || "请输入内容……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
if (props.siteId) {
fd.append("siteId", String(props.siteId));
}
if (props.articleId) {
fd.append("articleId", String(props.articleId));
}
if (props.scene) {
fd.append("scene", props.scene);
}
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
emit("uploaded", res.data.id);
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
}
} catch (e) {
console.warn("[rich-editor] 图片上传失败", e);
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
}
onBeforeUnmount(() => {
editorRef.value?.destroy();
});
// 外部回显值变化同步进编辑器(编辑页 load 后异步回填)
watch(
() => props.modelValue,
(v) => {
if (editorRef.value && v !== editorRef.value.getHtml()) {
editorRef.value.setHtml(v || "<p><br></p>");
}
}
);
</script>
<template>
<div
class="rich-editor"
:style="{ minHeight: (height || 360) + 'px', '--rich-editor-slate-min': slateMinHeight || 'max(240px, calc(100vh - 560px))' }"
>
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
:model-value="modelValue"
:defaultConfig="editorConfig"
mode="default"
style="height: 100%"
@onCreated="handleCreated"
@update:model-value="(v: string) => emit('update:modelValue', v)"
/>
</div>
</template>
<style scoped lang="scss">
.rich-editor {
display: flex;
flex-direction: column;
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
flex: 1;
overflow: visible;
// 长文显示不全修复(2026-09-10)同款口径:文本容器高度随内容自动增长,
// 整篇平铺由页面滚动;空态/短文 slate 区 min-height 铺满(调用方可用 slateMinHeight 覆写口径)
:deep(.w-e-text-container) {
height: auto !important;
}
:deep([data-slate-editor]) {
min-height: var(--rich-editor-slate-min) !important;
}
}
}
</style>
+236 -48
View File
@@ -1,14 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode): /* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示); * header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
* 面包屑首段=校名(落点为院系子站时后跟当前站点名),超管在校名前多一段可点「学校列表」(与返回图标同效)。 * 面包屑 = 学校列表(超管)> 校名 > 站点身份条 > 页面。
* 学校/站点切换一律离壳完成:学校在 /workbench,站点在站点管理行「进入管理」。 * 站点分层(docs/13 方案 C):
* URL 为站点上下文唯一事实源——主站 /sch/{code}/{page},子站 /sch/{code}/site/{siteCode}/{page};
* 站点身份条(SiteSwitcher)一步切换并停留当前功能页,上下文由 syncSiteFromRoute 同步;
* 菜单 page 保持主站形态,高亮剥段匹配、跳转按当前站点补段。
* 学校切换仍在壳外完成:/workbench 工作台选校。
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */ * 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
import { ref, computed, watch, onBeforeMount } from "vue"; import { ref, computed, watch, onBeforeMount } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute"; import { resolveMenuPage } from "@/utils/menuRoute";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
import SiteSwitcher from "@/components/SiteSwitcher.vue";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
const route = useRoute(); const route = useRoute();
@@ -31,18 +38,46 @@ const modList = computed(() => store.session.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null)); const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
/** 当前学校上下文(面包屑首段) */ /** 当前学校上下文(面包屑校名段) */
const school = computed(() => store.currentSchool); const school = computed(() => store.currentSchool);
const isSuper = computed(() => store.isSuperAdmin); const isSuper = computed(() => store.isSuperAdmin);
/** 面包屑校名后缀:操作落点非主站时跟随展示当前站点名(院系子站管理可见性, /** 当前落点是否院系子站(store 契约):决定身份条徽章、侧栏/面包屑身份色带与页头站点名(v2 §C.3 #2/#3) */
* 站点切换入口在站点管理行「进入管理」,壳上不再放切换器) */ const isSubSite = computed(() => store.isSubSite);
const currentSiteSuffix = computed(() => { /** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */
const s = store.currentSite; const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
const sc = school.value;
if (!s || !sc || s.id === sc.mainSiteId) return ""; /* 页头身份(docs/22 v3):学校名.站点名 —— 一处文字同时回答「哪个学校 + 哪个站点」。
return s.name || ""; 子站取站点名(云海大学.机电学院),主站固定写「主站」(云海大学.主站);
沿用既有 .shell-site 胶囊语汇(band-sub 橙 / band-main 中性白),不引新色值。 */
const siteName = computed(() => store.currentSite?.name || "");
const schoolName = computed(() => school.value?.schName || school.value?.mainSiteName || "");
/** 页头站点档位文案:主站不写站全名(会很长),统一「主站」 */
const siteLabel = computed(() => (isSubSite.value ? siteName.value : "主站"));
/** 完整身份串:学校名.站点名(学校未就绪时退化为站点名) */
const identityText = computed(() => {
const label = siteLabel.value;
if (!label) return "";
return schoolName.value ? `${schoolName.value}.${label}` : label;
}); });
const shellTitle = computed(() =>
identityText.value ? `${appName.value} · ${identityText.value}` : appName.value
);
const docTitle = computed(() => {
const t = String((route.meta as any)?.title || "");
// 多标签页可分辨:文档标题同样带「学校名.站点名」,与页头口径一致
if (!identityText.value) return t;
return t ? `${t} · ${identityText.value}` : identityText.value;
});
watch(docTitle, v => { if (v) document.title = v; }, { immediate: true });
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
const withSiteSeg = (page: string): string => {
const code = school.value?.mainSiteCode;
if (!page || !code || !page.startsWith("/sch/")) return page;
return adminPathOf(code, store.siteSegCode, schoolSubPath(page));
};
const flaten = (tree: any[], pid = 0): any[] => { const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = []; const arr: any[] = [];
@@ -70,6 +105,8 @@ const routeChange = (to: any) => {
const meta: any = to.meta || {}; const meta: any = to.meta || {};
const { modId, title, subOf } = meta; const { modId, title, subOf } = meta;
const navArr: any[] = []; const navArr: any[] = [];
// 菜单 page 为主站形态,匹配前剥掉站点中缀(docs/13 §4 剥段匹配)
const toLogic = stripSiteSeg(to.path || "");
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验) // 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
const expandGroupOf = (page: string) => { const expandGroupOf = (page: string) => {
@@ -95,7 +132,7 @@ const routeChange = (to: any) => {
const gp = menuArr.value.find((x: any) => x.id === parent.pid); const gp = menuArr.value.find((x: any) => x.id === parent.pid);
if (gp) navArr.push({ name: gp.name }); if (gp) navArr.push({ name: gp.name });
} }
navArr.push({ name: parent.name, page: parent.page }); navArr.push({ name: parent.name, page: withSiteSeg(parent.page) });
navArr.push({ name: title }); navArr.push({ name: title });
navPath.value = navArr; navPath.value = navArr;
return; return;
@@ -105,7 +142,7 @@ const routeChange = (to: any) => {
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致), // 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照 // 匹配不到再退回 modId 对照
const find: any = const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) || menuArr.value.find((x: any) => x.page && x.page === toLogic) ||
menuArr.value.find((x: any) => x.modId === modId); menuArr.value.find((x: any) => x.modId === modId);
if (find) { if (find) {
currPath.value = find.page || ""; currPath.value = find.page || "";
@@ -114,7 +151,7 @@ const routeChange = (to: any) => {
const pfind = menuArr.value.find((x: any) => x.id === find.pid); const pfind = menuArr.value.find((x: any) => x.id === find.pid);
if (pfind) navArr.push({ name: pfind.name }); if (pfind) navArr.push({ name: pfind.name });
} }
navArr.push({ name: find.name || title, page: find.page }); navArr.push({ name: find.name || title, page: withSiteSeg(find.page) });
navPath.value = navArr; navPath.value = navArr;
return; return;
} }
@@ -122,8 +159,8 @@ const routeChange = (to: any) => {
if (modId != null) { if (modId != null) {
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId); const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) { if (r) {
currPath.value = r.path; currPath.value = stripSiteSeg(r.path);
navPath.value = [{ name: (r.meta as any).title, page: r.path }]; navPath.value = [{ name: (r.meta as any).title, page: withSiteSeg(r.path) }];
return; return;
} }
} }
@@ -144,10 +181,12 @@ const linkMenu = (item: any) => {
window.location.href = page; window.location.href = page;
return; return;
} }
if (page === route.path) { // 子站上下文下菜单目标补站点段(主站形态 → 当前站点形态)
const target = withSiteSeg(page);
if (target === route.path) {
router.go(0); router.go(0);
} else { } else {
router.push(page); router.push(target);
} }
}; };
@@ -156,9 +195,13 @@ const linkUserInfo = () => {
}; };
const logout = () => { const logout = () => {
let url = window.location.href; let url = window.location.href;
url = "/cas/logout?service=" + url; url = "/logout?service=" + url;
window.location.href = ssoHost.value + url; window.location.href = ssoHost.value + url;
}; };
/** 返回首页:旧 BschHome 应用已下线(线上 /BschHome/front/index.html 返回 404),统一跳用户中心 userhome */
const linkHomePage = () => {
window.location.href = (baseHost.value || "") + "/userhome/front/index.html";
};
const childChange = (index: number) => { const childChange = (index: number) => {
const arr = modList.value || []; const arr = modList.value || [];
if (arr[index]) { if (arr[index]) {
@@ -167,22 +210,26 @@ const childChange = (index: number) => {
}; };
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖), /** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
* 兜底保留 modId 对照 */ * 兜底保留 modId 对照。图标为 ysch-icons(CSS mask 类,见 styles/icons.css,与 Notice 同一套);
* 友情链接/附件库内无完全对应语义,分别取 cshxd(路牌指引)、sjlm(文档层叠)近似 */
const getMenuIcon = (item?: any): string => { const getMenuIcon = (item?: any): string => {
const page: string = item?.page || ""; const page: string = item?.page || "";
if (page === "/article" || page.startsWith("/article")) return "ios-document"; if (page === "/article" || page.startsWith("/article")) return "icon-wenzhang";
if (page === "/category") return "ios-list-box"; if (page === "/category") return "icon-liebiao";
if (page === "/banner") return "ios-images"; if (page === "/banner") return "icon-tupian";
if (page === "/link") return "ios-link"; if (page === "/link") return "icon-cshxd";
if (page === "/attachment") return "ios-folder-open"; if (page === "/attachment") return "icon-sjlm";
if (page === "/site") return "ios-globe"; if (page === "/site") return "icon-zhandian";
// 事项库 R2(§7.5):办事事项取"申请"图标,服务字典取列表图标(前缀分支,优先于 modId 对照)
switch (item?.modId ?? item?.id) { switch (item?.modId ?? item?.id) {
case 1: return "ios-document"; case 1: return "icon-wenzhang";
case 2: return "ios-list-box"; case 2: return "icon-liebiao";
case 3: return "ios-images"; case 3: return "icon-tupian";
case 4: return "ios-link"; case 4: return "icon-cshxd";
case 5: return "ios-folder-open"; case 5: return "icon-sjlm";
case 6: return "ios-globe"; case 6: return "icon-zhandian";
case 21: return "icon-shenqing";
case 22: return "icon-liebiao";
default: return ""; default: return "";
} }
}; };
@@ -192,7 +239,34 @@ function goWorkbench() {
router.push("/workbench"); router.push("/workbench");
} }
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */ /**
* URL 站点段 → store 同步(docs/13 §3:URL 是站点上下文唯一事实源)。
* 无 siteCode = 主站形态;有则按 code 匹配本校站点(parentSiteId 归属校验),
* 非法/跨校 siteCode 打回同页主站形态并告警。
* 启动引导期(user 未装载 → schoolSites 为空)跳过,由 user 就绪后的 watch 兜底重跑。
*/
function syncSiteFromRoute() {
const sc = school.value;
if (!sc || !store.user) return;
const sites = (store.schoolSites || []) as any[];
if (!sites.length) return;
const { site: target, invalid } = resolveRouteSite(sites, sc.mainSiteId, route.params.siteCode as string);
if (invalid) {
Message.warning("站点不存在或无权限,已返回主站");
router.replace({ path: stripSiteSeg(route.path), query: route.query, hash: route.hash });
return;
}
if (target && store.currentSite?.id !== target.id) {
store.setCurrentSite({
id: target.id,
code: target.code,
path: target.path || target.code,
name: target.name,
});
}
}
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文,再按 URL 同步站点 */
watch( watch(
() => store.user, () => store.user,
u => { u => {
@@ -216,6 +290,8 @@ watch(
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id) sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
); );
} }
syncSiteFromRoute();
routeChange(route);
}, },
{ immediate: true } { immediate: true }
); );
@@ -226,22 +302,30 @@ onBeforeMount(() => {
watch( watch(
() => route.fullPath, () => route.fullPath,
() => routeChange(route) () => {
syncSiteFromRoute();
routeChange(route);
}
); );
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮 // 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
watch( watch(
() => [store.currentSchool?.mainSiteCode, store.session.modList.length], () => [store.currentSchool?.mainSiteCode, store.session.modList.length],
() => routeChange(route) () => {
syncSiteFromRoute();
routeChange(route);
}
); );
</script> </script>
<template> <template>
<div class="layout-wrap"> <div class="layout-wrap">
<header class="header-container fix"> <header class="header-container fix">
<!-- 云海 logo 徽标(07-seam-pure 定稿顶栏,皮肤见 yh-theme.css .logo-badge) -->
<span class="logo-badge fl"><img src="@/assets/images/yunhai-logo.svg" alt="云海" /></span>
<div class="fl align-middle icon-box"> <div class="fl align-middle icon-box">
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel"> <div title="首页" class="icon-btn" @click="linkHomePage">
<i class="iconfont icon-apps"></i> <i class="iconfont icon-shouye"></i>
</div> </div>
</div> </div>
<div class="delimiter fl align-middle"></div> <div class="delimiter fl align-middle"></div>
@@ -253,10 +337,18 @@ watch(
title="返回学校列表" title="返回学校列表"
@click="goWorkbench" @click="goWorkbench"
> >
<Icon type="ios-undo" :size="18" /> <Icon type="ios-arrow-back" :size="20" />
</a> </a>
<div v-if="isSuper" class="delimiter fl align-middle"></div> <div v-if="isSuper" class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4> <h4 class="fl align-middle" :title="shellTitle">
{{ appName }}<template v-if="identityText"
><span class="h-delimiter"></span
><span class="school-cap" :class="identityClass"
>{{ schoolName }}<span class="sc-sep">.</span
><span class="sc-site">{{ siteLabel }}</span></span
></template
>
</h4>
<div class="header-aside fr"> <div class="header-aside fr">
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" /> <img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo"> <a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
@@ -267,8 +359,11 @@ watch(
<i class="iconfont icon-logout"></i> <i class="iconfont icon-logout"></i>
</a> </a>
</div> </div>
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
<div class="theme-cap fr"><ThemeSwitcher /></div>
</header> </header>
<div class="left-menu" v-if="!isAlone"> <div class="left-menu" :class="identityClass" v-if="!isAlone">
<!-- 站点卡已于 docs/22 v3 撤除(与站点工作台重复);身份改由页头「学校名.站点名」承担 -->
<div class="menu-top-box"></div> <div class="menu-top-box"></div>
<ul> <ul>
<template v-for="(item, index) in modList" :key="'menu_li_' + index"> <template v-for="(item, index) in modList" :key="'menu_li_' + index">
@@ -276,7 +371,7 @@ watch(
<li> <li>
<a href="javascript:;" @click="childChange(index)"> <a href="javascript:;" @click="childChange(index)">
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i> <i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" /> <i v-if="getMenuIcon(item)" class="iconfont" :class="getMenuIcon(item)"></i>
{{ item.name }} {{ item.name }}
</a> </a>
</li> </li>
@@ -296,7 +391,7 @@ watch(
</template> </template>
<li v-else :class="item.page == currPath ? 'active' : ''"> <li v-else :class="item.page == currPath ? 'active' : ''">
<a href="javascript:;" @click="linkMenu(item)"> <a href="javascript:;" @click="linkMenu(item)">
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" /> <i v-if="getMenuIcon(item)" class="iconfont" :class="getMenuIcon(item)"></i>
{{ item.name }} {{ item.name }}
</a> </a>
</li> </li>
@@ -305,14 +400,27 @@ watch(
<div class="clear-box"></div> <div class="clear-box"></div>
</div> </div>
<div class="path-nav" :style="pathNavStyle"> <div class="path-nav" :class="identityClass" :style="pathNavStyle">
<Breadcrumb separator=">"> <Breadcrumb separator=">">
<!-- 首页段(常驻第一段):旧 BschHome 已下线,统一跳用户中心 userhome -->
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<span class="crumb-home">
<i class="iconfont icon-category"></i>
首页
</span>
</BreadcrumbItem>
</a>
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) --> <!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem> <BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
<!-- 面包屑首段 = 学校上下文(常驻);落点为院系子站时跟出当前站点名 --> <!-- 面包屑第二段 = 学校上下文(常驻,不承载站点信息) -->
<BreadcrumbItem v-if="school"> <BreadcrumbItem v-if="school">
<Icon type="ios-school" /> <Icon type="ios-school" />
{{ school.schName || school.mainSiteName }}<template v-if="currentSiteSuffix"> · {{ currentSiteSuffix }}</template> {{ school.schName || school.mainSiteName }}
</BreadcrumbItem>
<!-- 站点身份条(docs/13 §1):徽章+站点名▼,下拉一步切换;不传 to(否则 mixins-link 抢走点击) -->
<BreadcrumbItem v-if="store.currentSite">
<SiteSwitcher />
</BreadcrumbItem> </BreadcrumbItem>
<template v-for="(item, index) in navPath" :key="index"> <template v-for="(item, index) in navPath" :key="index">
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page"> <BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
@@ -337,7 +445,7 @@ watch(
</li> </li>
</ul> </ul>
<div class="link-panel"> <div class="link-panel">
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer"> <a :href="baseHost + '/userhome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
全部应用 全部应用
<i>→</i> <i>→</i>
</a> </a>
@@ -355,13 +463,23 @@ watch(
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
/* 超管返回学校列表图标:header 常驻入口(校级账号不渲染) */ /* 超管返回学校列表:header 常驻入口(校级账号不渲染)。
与「首页」按钮同规格的可点方块(26×26 + hover 底)+ 左右 10px 呼吸 ——
原先是裸 18×18 图标夹在两条分隔线之间(x=126/146),既无点击热区、又比首页按钮小一档;
图标同时从 ios-undo(字形像「拐角」)换成 ios-arrow-back。 */
.back-workbench { .back-workbench {
width: 26px;
height: 26px;
margin: 0 10px;
border-radius: 5px;
color: #fff; color: #fff;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
transition: background 0.16s;
&:hover { &:hover {
opacity: 0.85; background: rgba(255, 255, 255, 0.16);
opacity: 1;
} }
} }
.no-site-tip { .no-site-tip {
@@ -373,11 +491,81 @@ watch(
font-size: 14px; font-size: 14px;
} }
/* 顶栏浮动作增补:logo 徽标垂直居中(56-30)/2=13px;配色胶囊同高对齐。
接缝条与站点身份带(原 .path-nav::after 3px 色带)已由 yh-theme.css 统一接管
(band-sub 时 ::before 让位身份橙),本组件不再画色带,仅保留 band-* 类名作档位 */
.header-container .logo-badge {
margin: 13px 10px 0 14px;
}
.theme-cap {
height: 56px;
margin-right: 6px;
}
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。 /* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
layout.css 全局 .main-wrapper 只有 min-height:100% 无 height, layout.css 全局 .main-wrapper 只有 min-height:100% 无 height,
子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。 子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点(:348-349) */ scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点 */
.base-wrap-main .main-wrapper { .base-wrap-main .main-wrapper {
height: 100%; height: 100%;
} }
/* 高度链顶层:layout.css 只定义到 .base-wrap-main{height:calc(100% - 56px)},
其百分比基于 .layout-wrap——本组件根节点,无高度定义则整条链解析失败
(UserBase 同名壳在 layout.vue 全局样式中补了这一层,此前 CMS 缺失) */
.layout-wrap {
height: 100%;
}
/* 左侧菜单图标与文字间距(ysch-icons mask 类,写法与 Notice layout.vue 一致) */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 页头身份(docs/22 v3):云海CMS | 学校名.站点名
h4 截断——学校名+站名可能很长,完整串在 :title 与 document.title。
胶囊色沿用原 .shell-site 取值(主站 rgba(255,255,255,.18) / 子站橙 rgba(255,153,0,.32)),不引新色值。 */
.header-container h4 {
max-width: 46vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.h-delimiter {
display: inline-block;
width: 1px;
height: 14px;
margin: 0 10px;
vertical-align: -2px;
background: rgba(255, 255, 255, 0.4);
}
.school-cap {
font-size: 13px;
font-weight: normal;
}
.sc-sep {
opacity: 0.75;
}
.sc-site {
padding: 2px 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.18);
}
.school-cap.band-sub .sc-site {
background: rgba(255, 153, 0, 0.32);
}
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内)。
scoped 特异性 .crumb-home .iconfont[data-v-x](0,2,1)> .path-nav .icon-category(0,2,0),可覆盖 */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
}
.crumb-home .iconfont {
position: static;
top: 0;
}
</style> </style>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useCmsStore } from "@/store";
import { adminPathOf, schoolSubPath } from "@/utils/sitePath";
/**
* 站点身份条(docs/13 方案 C):面包屑站点格内的「徽章 + 站点名 + 下拉」。
* 数据源 store.schoolSites(主站 + 本校院系子站,零后端改动);选中只改 URL,
* 站点上下文由 SchoolLayout.syncSiteFromRoute 按 URL 段同步——见 docs/13 §1.3/§3。
* 不用 iView Dropdown:分组头/徽章/✓/path 次级列的表达力不足且无可靠收起 API,自绘面板。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
const open = ref(false);
const anchor = ref<HTMLElement | null>(null);
const schoolCode = computed(() => store.currentSchool?.mainSiteCode || "");
const mainSite = computed(
() => store.schoolSites.find((s: any) => s.id === store.currentSchool?.mainSiteId) || null
);
const subSites = computed(() =>
store.schoolSites.filter((s: any) => s.parentSiteId === store.currentSchool?.mainSiteId)
);
/** 当前落点是否子站:主站徽章/配色/落点判定统一走 store 契约 */
const isSub = computed(() => store.isSubSite);
const currentName = computed(() => store.currentSite?.name || "");
/** 当前功能页子路径:切换站点停留当前页(剥掉站点段与学校前缀) */
const curSub = computed(() => schoolSubPath(route.path) || "/article");
function pick(site: any) {
open.value = false;
if (!site) return;
const isMain = site.id === store.currentSchool?.mainSiteId;
const target = adminPathOf(schoolCode.value, isMain ? null : site.code, curSub.value);
if (target === route.path) return;
router.push(target);
}
/** 底部入口:管理站点集合(带段=保持当前站点上下文,语义与菜单「站点管理」一致) */
function gotoManage() {
open.value = false;
router.push(adminPathOf(schoolCode.value, store.siteSegCode, "site"));
}
function onDocClick(e: MouseEvent) {
if (!open.value) return;
if (anchor.value && !anchor.value.contains(e.target as Node)) {
open.value = false;
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open.value = false;
}
onMounted(() => {
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
});
</script>
<template>
<span ref="anchor" class="site-identity" :class="{ sub: isSub }">
<span class="bdg" :class="isSub ? 'bdg-sub' : 'bdg-main'" @click.stop="open = !open">
{{ isSub ? "院系子站" : "校主站" }}
</span>
<b class="site-name" @click.stop="open = !open">{{ currentName }}</b>
<Icon type="ios-arrow-down" class="caret" @click.stop="open = !open" />
<div v-if="open" class="site-switcher-dd" @click.stop>
<div class="dd-cap">切换管理站点(当前:<b>{{ currentName }}</b>)</div>
<div class="dd-group">
<div v-if="mainSite" class="dd-item" :class="{ active: !isSub }" @click="pick(mainSite)">
<span class="check">{{ !isSub ? "✓" : "" }}</span>
<span>{{ mainSite.name }}</span>
<span class="bdg bdg-main">主站</span>
<span class="code">{{ mainSite.path || mainSite.code }}</span>
</div>
</div>
<div class="dd-group">
<div class="dd-cap sub-cap">院系子站({{ subSites.length }})</div>
<div v-if="!subSites.length" class="dd-empty">本校暂无院系子站(可在「站点管理」新建)</div>
<div
v-for="s in subSites"
:key="s.id"
class="dd-item"
:class="{ active: store.currentSite?.id === s.id }"
@click="pick(s)"
>
<span class="check">{{ store.currentSite?.id === s.id ? "✓" : "" }}</span>
<span>{{ s.name }}</span>
<span class="bdg bdg-sub">院系</span>
<span class="code">{{ s.path || s.code }}</span>
</div>
</div>
<div class="dd-foot" @click="gotoManage">前往「站点管理」维护站点 →</div>
</div>
</span>
</template>
<style scoped lang="scss">
/* 徽章:实心色块对齐已验收原型(bdg-main #577afb / bdg-sub #ff9900);
iView Tag 预设为浅底描边样式,视觉与原型不一致,故自绘 */
.bdg {
font-size: 10px;
border-radius: 3px;
padding: 1px 5px;
color: #fff;
line-height: 16px;
flex-shrink: 0;
cursor: pointer;
}
.bdg-main {
background: #577afb;
}
.bdg-sub {
background: #ff9900;
}
.site-identity {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 3px 8px;
border-radius: 4px;
position: relative;
&:hover {
background: #f5f7fa;
}
.site-name {
color: #4465d4;
font-weight: bold;
}
.caret {
font-size: 10px;
color: #c0c4cc;
}
&.sub {
background: #fff7ec;
&:hover {
background: #ffefd6;
}
.site-name {
color: #d48806;
}
}
}
.site-switcher-dd {
position: absolute;
top: calc(100% + 12px);
left: -8px;
min-width: 300px;
background: #fff;
color: #272829;
border-radius: 8px;
box-shadow: 0 8px 28px rgba(31, 42, 58, 0.2);
overflow: hidden;
z-index: 10;
text-align: left;
animation: dd-in 0.15s ease-out;
cursor: default;
.dd-cap {
padding: 10px 14px 4px;
font-size: 12px;
color: #999;
}
.sub-cap {
padding: 2px 14px;
}
.dd-group {
padding: 6px 0;
& + .dd-group {
border-top: 1px solid #f0f2f5;
}
}
.dd-empty {
padding: 6px 14px 8px;
font-size: 12px;
color: #c0c4cc;
}
.dd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
font-size: 13px;
cursor: pointer;
&:hover {
background: #eff4ff;
}
&.active {
background: #eff4ff;
color: #4465d4;
font-weight: bold;
}
.check {
color: #577afb;
font-weight: bold;
width: 11px;
}
.code {
margin-left: auto;
color: #c0c4cc;
font-size: 11px;
}
}
.dd-foot {
border-top: 1px solid #f0f2f5;
padding: 9px 14px;
font-size: 12px;
color: #577afb;
cursor: pointer;
background: #fafbfc;
&:hover {
background: #f0f3ff;
}
}
}
@keyframes dd-in {
from {
transform: translateY(-6px);
}
to {
transform: none;
}
}
</style>
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
/**
* 配色切换器(yh-theme §6.5)行为回归:
* 点主题色 → <html data-theme> 与 localStorage 同步;开关 → side-deep/side-light、grad-*、
* side-plain 档位类名互斥切换;胶囊点击开合弹层。沿用 createApp 手写挂载桩风格。
*/
import { beforeEach, describe, expect, it } from "vitest";
import { createApp, nextTick } from "vue";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
import { THEME_STORAGE_KEY, loadThemeState } from "@/utils/theme";
let host: HTMLDivElement;
let app: ReturnType<typeof createApp>;
function mount() {
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(ThemeSwitcher);
app.mount(host);
}
beforeEach(() => {
localStorage.clear();
const el = document.documentElement;
el.removeAttribute("data-theme");
["side-deep", "side-light", "grad-on", "grad-off", "side-plain"].forEach(c =>
el.classList.remove(c)
);
app?.unmount();
host?.remove();
mount();
});
function options(): HTMLButtonElement[] {
return Array.from(host.querySelectorAll(".theme-opt"));
}
function switches(): HTMLInputElement[] {
return Array.from(host.querySelectorAll(".sw input"));
}
/** 按 i 元素内联背景色定位主题按钮(swatch 色 = 定稿表) */
function optionByColor(color: string): HTMLButtonElement {
const el = options().find(b => (b.querySelector("i") as HTMLElement).style.background === color);
if (!el) throw new Error(`option not found: ${color}`);
return el;
}
function click(el: HTMLElement) {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
describe("ThemeSwitcher", () => {
it("初始态:无存储 → 默认档 blue 高亮,active 类唯一", () => {
const active = options().filter(b => b.classList.contains("active"));
expect(active).toHaveLength(1);
expect(active[0].textContent).toContain("原版靛蓝");
});
it("点「云海蓝」→ html[data-theme=ocean] 且持久化", () => {
click(optionByColor("rgb(47, 124, 224)"));
expect(document.documentElement.dataset.theme).toBe("ocean");
expect(loadThemeState().theme).toBe("ocean");
expect(JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)!).theme).toBe("ocean");
});
it("关「侧栏深色」→ side-deep 移除、side-light 挂上(互斥)", () => {
expect(document.documentElement.classList.contains("side-deep")).toBe(true);
const [follow, deep] = switches();
void follow;
deep.checked = false;
deep.dispatchEvent(new Event("change", { bubbles: true }));
nextTick();
const cl = document.documentElement.classList;
expect(cl.contains("side-deep")).toBe(false);
expect(cl.contains("side-light")).toBe(true);
expect(loadThemeState().sideDeep).toBe(false);
});
it("关「侧栏跟随顶栏色」→ side-plain 挂上", () => {
const [follow] = switches();
follow.checked = false;
follow.dispatchEvent(new Event("change", { bubbles: true }));
nextTick();
expect(document.documentElement.classList.contains("side-plain")).toBe(true);
expect(loadThemeState().sideFollow).toBe(false);
});
it("胶囊点击开合弹层", async () => {
const btn = host.querySelector(".theme-btn") as HTMLElement;
const pop = host.querySelector(".theme-pop") as HTMLElement;
expect(pop.classList.contains("open")).toBe(false);
click(btn);
await nextTick();
expect(pop.classList.contains("open")).toBe(true);
click(btn);
await nextTick();
expect(pop.classList.contains("open")).toBe(false);
});
it("挂载前已有存储(teal+浅色+无渐变)→ 按钮态与 html 档恢复", () => {
localStorage.setItem(
THEME_STORAGE_KEY,
JSON.stringify({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: true })
);
app.unmount();
mount();
expect(document.documentElement.dataset.theme).toBe("teal");
const active = options().find(b => b.classList.contains("active"));
expect(active?.textContent).toContain("松石绿");
const [, deep, grad] = switches();
expect(deep.checked).toBe(false);
expect(grad.checked).toBe(false);
});
});
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
import { onBeforeUnmount, onMounted, ref } from "vue";
import {
loadThemeState,
saveThemeState,
applyThemeState,
type ThemeName,
type ThemeState,
} from "@/utils/theme";
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
{ name: "teal", label: "松石绿", color: "#0d9488" },
];
const wrapEl = ref<HTMLElement | null>(null);
const open = ref(false);
const state = ref<ThemeState>(loadThemeState());
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
applyThemeState(state.value);
function commit(patch: Partial<ThemeState>) {
state.value = { ...state.value, ...patch };
applyThemeState(state.value);
saveThemeState(state.value);
}
function toggle(ev: MouseEvent) {
ev.stopPropagation();
open.value = !open.value;
}
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
function onDocClick(ev: MouseEvent) {
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
open.value = false;
}
}
onMounted(() => document.addEventListener("click", onDocClick, true));
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
</script>
<template>
<div ref="wrapEl" class="theme-wrap">
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
</svg>
<span>配色</span>
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
</svg>
</button>
<div class="theme-pop" :class="{ open }">
<p class="tp-head">主题色</p>
<div class="tp-grid">
<button
v-for="t in THEME_META"
:key="t.name"
type="button"
class="theme-opt"
:class="{ active: state.theme === t.name }"
@click="commit({ theme: t.name })"
>
<i :style="{ background: t.color }"></i>{{ t.label }}
</button>
</div>
<div class="tp-sep"></div>
<p class="tp-head">侧栏样式</p>
<label class="tp-row">
<span>侧栏跟随顶栏色</span>
<span class="sw">
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<label class="tp-row" style="margin-top: 9px">
<span>侧栏深色(关=浅色)</span>
<span class="sw">
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<div class="tp-sep"></div>
<label class="tp-row">
<span>顶部过渡色(渐变)</span>
<span class="sw">
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
</div>
</div>
</template>
<style scoped>
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
.theme-wrap {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 11px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 12.5px;
transition: background 0.16s, border-color 0.16s;
}
.theme-btn:hover,
.theme-btn.on {
background: rgba(255, 255, 255, 0.26);
border-color: rgba(255, 255, 255, 0.6);
}
.theme-btn svg {
width: 14px;
height: 14px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.theme-btn svg circle {
fill: currentColor;
stroke: none;
}
.theme-btn .caret {
width: 11px;
height: 11px;
opacity: 0.85;
transition: transform 0.18s;
}
.theme-btn.on .caret {
transform: rotate(180deg);
}
.theme-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 224px;
background: #fff;
color: var(--text, #1e2635);
border-radius: 12px;
border: 1px solid var(--line, #e9edf5);
padding: 12px;
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: 0.18s;
z-index: 40;
}
.theme-pop.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.tp-head {
font-size: 11.5px;
color: var(--text-3, #98a1b2);
letter-spacing: 0.5px;
margin: 0 2px 8px;
}
.tp-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.theme-opt {
display: flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 8px;
cursor: pointer;
text-align: left;
border: 1px solid var(--line, #e9edf5);
border-radius: 8px;
background: #fff;
color: var(--text-2, #5a6478);
font-size: 12px;
transition: 0.15s;
font-family: inherit;
}
.theme-opt:hover {
border-color: #cfd7e6;
background: #fafbfe;
}
.theme-opt i {
width: 13px;
height: 13px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.theme-opt.active {
border-color: var(--brand, #577afb);
color: var(--brand, #577afb);
background: var(--brand-soft, #eef2ff);
font-weight: 600;
}
.tp-sep {
height: 1px;
background: var(--line, #e9edf5);
margin: 12px 0 10px;
}
.tp-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
color: var(--text-2, #5a6478);
cursor: pointer;
padding: 0 2px;
}
.sw {
position: relative;
width: 38px;
height: 21px;
flex: 0 0 auto;
}
.sw input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
z-index: 1;
}
.sw i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #d7dce6;
transition: background 0.2s;
pointer-events: none;
}
.sw i::after {
content: "";
position: absolute;
top: 2.5px;
left: 2.5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
transition: transform 0.2s;
}
.sw input:checked + i {
background: var(--brand, #577afb);
}
.sw input:checked + i::after {
transform: translateX(17px);
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
/**
* 工作台同款深色顶栏(超管平台级页面共用):
* 标题 + 可选「返回」图标 + 头像/姓名(用户中心)+ 退出登录。
* 使用方:SchoolWorkbench(无返回)、ThemeManage(backTo=/workbench)。
*/
const props = defineProps<{
title: string;
/** 传入则渲染返回图标按钮(如返回工作台) */
backTo?: string;
}>();
const router = useRouter();
const store = useCmsStore();
const userInfo = computed(() => ({
name: store.session.baseUserName || "管理员",
avatar: store.session.baseUserAvatar,
}));
function goBack() {
if (props.backTo) {
router.push(props.backTo);
}
}
const linkUserInfo = () => {
window.location.href = config.baseHost + "/v/userinfo";
};
const logout = () => {
const url = "/logout?service=" + window.location.href;
window.location.href = config.ssoHost + url;
};
</script>
<template>
<header class="wb-header">
<h4>{{ title }}</h4>
<div class="wb-header-aside">
<a v-if="backTo" href="javascript:void(0);" class="back" title="返回工作台" @click="goBack">
<Icon type="ios-arrow-back" :size="20" />
</a>
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="wb-avatar" @click="linkUserInfo" />
<a class="name" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
<span class="delimiter"></span>
<a href="javascript:;" class="logout" @click="logout" title="退出登陆">
<i class="iconfont icon-logout"></i>
</a>
</div>
</header>
</template>
<style scoped lang="scss">
.wb-header {
height: 56px;
flex-shrink: 0;
background: #1f2a3a;
color: #fff;
display: flex;
align-items: center;
padding: 0 24px;
h4 {
color: #fff;
font-size: 16px;
margin: 0;
}
.wb-header-aside {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
.name {
color: #fff;
}
.back {
color: #fff;
display: inline-flex;
align-items: center;
}
.delimiter {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.3);
display: inline-block;
}
.wb-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
}
.logout {
color: #fff;
display: inline-flex;
align-items: center;
}
}
}
</style>
+38 -12
View File
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted, watch } from "vue"; import { reactive, ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message, Tag } from "view-ui-plus"; import { Message, Tag } from "view-ui-plus";
import Utils from "@/utils/index"; import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import { articleApi, categoryApi } from "@/api/module/cms"; import { articleApi, categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import { ArticlePageItem, CategoryTreeNode } from "@/types/cms"; import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
import { adminPathOf } from "@/utils/sitePath";
/** /**
* 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出): * 文章列表面板(B5 栏目工作台组件化,自 ArticleList.vue 原样抽出):
@@ -17,9 +18,12 @@ import { ArticlePageItem, CategoryTreeNode } from "@/types/cms";
const props = defineProps<{ const props = defineProps<{
categoryId?: number; categoryId?: number;
fixedCategory?: boolean; fixedCategory?: boolean;
/** 表格高度额外扣减(固定栏目模式嵌入工作台时扣面板头高度) */
extraHeight?: number;
}>(); }>();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useCmsStore(); const store = useCmsStore();
const rows = ref<ArticlePageItem[]>([]); const rows = ref<ArticlePageItem[]>([]);
@@ -29,7 +33,9 @@ const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10")); const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const search = reactive({ const search = reactive({
categoryId: undefined as number | undefined, // P-D1:栏目页概览「查看全部 →」以 /article?categoryId={id} 深链进入,读 query 预筛选
// (query 是字符串,转数字才能对上 Option 的 value 并回显下拉选中项)
categoryId: Number((route.query?.categoryId as any)) || undefined,
status: undefined as number | undefined, status: undefined as number | undefined,
keyword: "", keyword: "",
}); });
@@ -45,7 +51,12 @@ const statusDict = [
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知"; const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default"; const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }); const tableHeight = Utils.useTableHeight({
isSearch: true,
isOperat: !props.fixedCategory,
isPage: true,
otherH: props.extraHeight ?? 0,
});
const columns = [ const columns = [
{ {
@@ -58,7 +69,8 @@ const columns = [
h( h(
"a", "a",
{ {
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" }, class: "operating-link",
style: { cursor: "pointer" },
onClick: () => goEdit(p.row.id), onClick: () => goEdit(p.row.id),
}, },
p.row.title p.row.title
@@ -94,7 +106,7 @@ const columns = [
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)), Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
]; ];
if (p.row.status !== 1) { if (p.row.status !== 1) {
ops.push(Utils.operatingHandle(h, "发布", "icon-apps", () => doPublish(p.row))); ops.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => doPublish(p.row)));
} }
if (p.row.status === 1) { if (p.row.status === 1) {
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row))); ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
@@ -137,7 +149,9 @@ async function run() {
const page = await articleApi const page = await articleApi
.page({ .page({
siteId: store.siteId, siteId: store.siteId,
categoryId: props.fixedCategory ? props.categoryId : search.categoryId, // 后端 ArticlePageQuery 字段为 categoryIds(List<Integer>),传 categoryId 会绑定不上、
// 退化成全站检索(栏目工作台"切节点表格不变"的根因)
categoryIds: props.fixedCategory ? props.categoryId : search.categoryId,
status: search.status, status: search.status,
keyword: search.keyword || undefined, keyword: search.keyword || undefined,
page: pageNum.value, page: pageNum.value,
@@ -166,7 +180,8 @@ function goEdit(id?: number) {
return; return;
} }
const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : ""; const query = props.fixedCategory && props.categoryId ? `?categoryId=${props.categoryId}` : "";
router.push(id ? `/sch/${code}/article/edit/${id}${query}` : `/sch/${code}/article/edit${query}`); // 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
router.push(adminPathOf(code, store.siteSegCode, id ? `article/edit/${id}` : "article/edit") + query);
} }
async function doPublish(row: ArticlePageItem) { async function doPublish(row: ArticlePageItem) {
@@ -201,6 +216,18 @@ function doDelete(row: ArticlePageItem) {
}); });
} }
// 关键字即输即搜(bsch 规范 §2:300ms 防抖,无检索按钮)
let keywordTimer: ReturnType<typeof setTimeout>;
watch(
() => search.keyword,
() => {
clearTimeout(keywordTimer);
keywordTimer = setTimeout(() => {
pageNum.value = 1;
run();
}, 300);
}
);
onMounted(async () => { onMounted(async () => {
await loadCategories(); await loadCategories();
await run(); await run();
@@ -222,7 +249,8 @@ watch(() => store.siteId, async () => {
<template> <template>
<div class="article-list-panel"> <div class="article-list-panel">
<div class="fix mb-12"> <!-- 固定栏目模式:新建入口由工作台面板头承担,避免双按钮重复 -->
<div v-if="!fixedCategory" class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="goEdit()"> <Button class="fl" type="primary" size="large" @click="goEdit()">
<Icon type="ios-add" /> <Icon type="ios-add" />
新建文章 新建文章
@@ -252,17 +280,15 @@ watch(() => store.siteId, async () => {
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option> <Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select> </Select>
<Input <Input
search
enter-button
class="fr" class="fr"
placeholder="标题关键字" placeholder="标题关键字"
style="width: 220px" style="width: 200px"
v-model="search.keyword" v-model="search.keyword"
@on-search="pageNum = 1; run()"
@on-clear="pageNum = 1; run()" @on-clear="pageNum = 1; run()"
/> />
</div> </div>
<Table <Table
class="link-table table-bottom-box"
:columns="columns" :columns="columns"
:data="rows" :data="rows"
:height="tableHeight" :height="tableHeight"
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { Tag } from "view-ui-plus";
import { articleApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { ArticlePageItem } from "@/types/cms";
import { adminPathOf } from "@/utils/sitePath";
/**
* 栏目内容概览(P-D1,v2 §B.3):/category 右侧列表型栏目的降级面板。
* 定位=只读概览;文章的发布/下架/置顶/删除只在 /article(唯一操作台)做。
*
* 统计口径(手册 P-D1 定稿,勿自行改):
* - 范围 = 选中栏目的**直属 + 全部子孙栏目**。后端 ArticlePageQuery.categoryIds 是不递归的 IN 查询,
* 故子孙 id 由前端从栏目树展开(与门户 PortalPageRenderer.collectListDescendantIds 同语义)。
* - 状态 = **全状态计数**(草稿/已发布/已下架全部计入,不带 status 过滤;系统内无"回收站"概念)。
* - 一次 page 请求同时拿到总数(total)与最近 5 条(list,后端按置顶降序 + 创建时间降序)。
*/
const props = defineProps<{
categoryId: number;
categoryName?: string;
/** 全量栏目树(CategoryManage 的 treeData,含 W2 虚拟根;按 id 定位,虚拟根 id=0 不会命中真实栏目) */
tree?: any[];
}>();
const router = useRouter();
const store = useCmsStore();
const total = ref(0);
const rows = ref<ArticlePageItem[]>([]);
const loading = ref(false);
const statusDict = [
{ value: 0, label: "草稿", color: "default" },
{ value: 1, label: "已发布", color: "success" },
{ value: 2, label: "已下架", color: "warning" },
];
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
function findNode(list: any[], id: number): any {
for (const n of list || []) {
if (Number(n.id) === Number(id)) return n;
const hit = findNode(n.children || [], id);
if (hit) return hit;
}
return null;
}
function collectIds(node: any, acc: number[]): number[] {
acc.push(Number(node.id));
(node.children || []).forEach((c: any) => collectIds(c, acc));
return acc;
}
/** 统计范围 id:树未就绪(或未命中)时退化为仅直属栏目 */
const scopeIds = computed<number[]>(() => {
const node = findNode(props.tree || [], props.categoryId);
return node ? collectIds(node, []) : [Number(props.categoryId)];
});
const scopeKey = computed(() => scopeIds.value.join(","));
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
const page = await articleApi
.page({
siteId: store.siteId,
categoryIds: scopeKey.value,
page: 1,
pageSize: 5,
})
.send();
total.value = page?.total || 0;
rows.value = (page?.list || []).slice(0, 5);
} finally {
loading.value = false;
}
}
/** 跳 /article 深链预筛选(子栏目行按自身栏目过滤,父栏目"查看全部"按父栏目过滤) */
function goArticles(categoryId?: number) {
const code = store.schoolCode;
if (!code) return;
const id = categoryId ?? props.categoryId;
router.push(adminPathOf(code, store.siteSegCode, "article") + `?categoryId=${id}`);
}
// 树稍后到达(子孙变化)与切换栏目都重取;站点切换由 CategoryManage 清选中后重建本组件
watch(scopeKey, run);
onMounted(run);
</script>
<template>
<div class="category-content-summary">
<div class="ccs-stat fix">
<span class="ccs-total">共 <b>{{ total }}</b> 篇</span>
<span class="ccs-scope">
{{ categoryName || "本栏目" }}及子栏目({{ scopeIds.length }} 个栏目)· 草稿/已发布/已下架全部计入
</span>
<Button class="fr" size="small" @click="goArticles()">查看全部 →</Button>
</div>
<div v-if="loading" class="ccs-empty">加载中…</div>
<ul v-else-if="rows.length" class="ccs-list">
<li v-for="row in rows" :key="row.id" class="ccs-row fix">
<a class="ccs-title operating-link" :title="row.title" @click="goArticles(row.categoryId)">{{ row.title }}</a>
<Tag class="ccs-tag" :color="statusColor(row.status)">{{ statusText(row.status) }}</Tag>
<span class="ccs-time">{{ row.publishTime || row.createTime || "-" }}</span>
</li>
</ul>
<div v-else class="ccs-empty">
<p>本栏目及子栏目下暂无文章</p>
<p class="ccs-empty-tip">可在上方「新建文章」直接发布到本栏目,或到「文章列表」页检索全站内容。</p>
</div>
</div>
</template>
<style scoped lang="scss">
.category-content-summary {
background: #fff;
border-radius: 4px;
padding: 4px 16px 16px;
}
.ccs-stat {
padding: 14px 0 10px;
border-bottom: 1px solid #f0f0f0;
line-height: 24px;
}
.ccs-total {
font-size: 14px;
color: #515a6e;
b {
font-size: 20px;
color: #2d8cf0;
margin: 0 2px;
}
}
.ccs-scope {
margin-left: 10px;
font-size: 12px;
color: #808695;
}
.ccs-list {
list-style: none;
margin: 0;
padding: 0;
}
.ccs-row {
padding: 10px 0;
border-bottom: 1px dashed #f0f0f0;
display: flex;
align-items: center;
&:last-child {
border-bottom: none;
}
}
.ccs-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.ccs-tag {
flex: none;
margin: 0 10px;
}
.ccs-time {
flex: none;
font-size: 12px;
color: #808695;
}
.ccs-empty {
padding: 32px 0;
text-align: center;
color: #808695;
}
.ccs-empty-tip {
margin-top: 6px;
font-size: 12px;
}
</style>
+224 -30
View File
@@ -1,16 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { ThemeVo } from "@/types/cms"; import { ThemeVo } from "@/types/cms";
import ImageAssetField from "@/components/ImageAssetField.vue";
import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm"; import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm";
/** /**
* 主题参数微调表单(方案 §6.3):按 theme.json params schema 动态生成。 * 主题参数微调表单(方案 §6.3 + 工作项 T):按 theme.json params schema 动态生成,
* color→ColorPicker、select→Select、image→Input(手填 /cms/upload/ 路径,走附件上传后回填)、 * 值语义为「皮肤包级参数值」(`ThemeVo.paramValues`,落 themes/.meta/{name}.json)——
* text→Input。父级 v-model 绑定 FormModelValue(声明过的 key,值已兜底为默认)。 * 一包一份值,引用同包的站点共享;需要差异走「复制皮肤包」。
* 2026-09-20 布局重排(用户反馈"颜色设置竖着太长"):
* - color 参数 → 色卡网格(横排自动换行,auto-fill 150px;真实主题 12 个色参数不再拉出 12 行长蛇);
* - select/image/text → 两列行式(label + 控件同行);image 走 ImageAssetField(上传 + 手填双通道);
* - 非法值红框 + 行内提示;「已修改」标记 + 单项恢复默认 + 整体重置(对照 params[].defaultValue)。
*/ */
const props = defineProps<{ const props = defineProps<{
theme?: ThemeVo; theme?: ThemeVo;
modelValue: FormModelValue; modelValue: FormModelValue;
/** image 参数上传归属站点(皮肤包管理借用内容源站点;缺省则仅可手填路径) */
siteId?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -19,9 +26,12 @@ const emit = defineEmits<{
const themeName = computed(() => props.theme?.title || props.theme?.name || ""); const themeName = computed(() => props.theme?.title || props.theme?.name || "");
const params = computed(() => props.theme?.params || []); const params = computed(() => props.theme?.params || []);
const colorParams = computed(() => params.value.filter(p => p.type === "color"));
const otherParams = computed(() => params.value.filter(p => p.type !== "color"));
const badKeys = computed(() => const badKeys = computed(() =>
props.theme ? invalidParamKeys(props.theme, props.modelValue) : [] props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
); );
const modifiedCount = computed(() => params.value.filter(p => isModified(p.key)).length);
function setValue(key: string, value: string) { function setValue(key: string, value: string) {
emit("update:modelValue", { ...props.modelValue, [key]: value }); emit("update:modelValue", { ...props.modelValue, [key]: value });
@@ -31,6 +41,25 @@ function displayValue(key: string): string {
return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue); return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue);
} }
/** 是否偏离包内声明的默认值(= 会被写进 .meta paramValues 的那些项) */
function isModified(key: string): boolean {
const p = params.value.find(x => x.key === key);
if (!p) return false;
return displayValue(key) !== (p.defaultValue ?? "");
}
function resetOne(key: string) {
const p = params.value.find(x => x.key === key);
setValue(key, p?.defaultValue ?? "");
}
/** 整体重置 = 提交全默认值模型(父级据此给后端空 Map,恢复 theme.json 默认) */
function resetAll() {
const next: FormModelValue = {};
params.value.forEach(p => (next[p.key] = p.defaultValue ?? ""));
emit("update:modelValue", next);
}
function isBad(key: string): boolean { function isBad(key: string): boolean {
return badKeys.value.includes(key); return badKeys.value.includes(key);
} }
@@ -38,71 +67,236 @@ function isBad(key: string): boolean {
<template> <template>
<div v-if="!theme" class="empty-tip">请先选择皮肤包</div> <div v-if="!theme" class="empty-tip">请先选择皮肤包</div>
<Form v-else :label-width="90" class="theme-param-form"> <div v-else class="theme-param-form">
<div class="param-head"> <div class="param-head">
参数微调 · {{ themeName }} <span class="param-head-title">参数微调 · {{ themeName }}</span>
<span v-if="badKeys.length" class="param-warn"> <span v-if="badKeys.length" class="param-warn">
有 {{ badKeys.length }} 个非法值未生效(将回退皮肤默认) {{ badKeys.length }} 个非法值未生效(保存时将回退皮肤默认)
</span> </span>
<span class="param-head-tip">
{{ modifiedCount ? `已修改 ${modifiedCount} 项` : "未修改(全部为包内默认值)" }}
· 保存后引用该包的站点门户立即生效
</span>
<Button v-if="modifiedCount" size="small" ghost class="reset-all" @click="resetAll">整体恢复默认</Button>
</div> </div>
<FormItem v-for="p in params" :key="p.key" :label="p.label">
<!-- color:色板 --> <!-- color:色卡网格(横排) -->
<div v-if="colorParams.length" class="param-grid">
<div
v-for="p in colorParams"
:key="p.key"
class="param-chip"
:class="{ bad: isBad(p.key), modified: isModified(p.key) }"
:title="p.key"
>
<ColorPicker <ColorPicker
v-if="p.type === 'color'"
:model-value="displayValue(p.key)" :model-value="displayValue(p.key)"
recommend recommend
@on-change="(c: string) => setValue(p.key, c)" @on-change="(c: string) => setValue(p.key, c)"
/> />
<!-- select:下拉 --> <span class="chip-name">
{{ p.label }}
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
</span>
<span class="chip-hex">{{ displayValue(p.key) }}</span>
<Button
v-if="isModified(p.key)"
size="small"
ghost
class="chip-reset"
@click="resetOne(p.key)"
>
恢复默认
</Button>
</div>
</div>
<!-- select / image / text:两列行式 -->
<div v-if="otherParams.length" class="param-rows">
<div v-for="p in otherParams" :key="p.key" class="param-row" :class="{ bad: isBad(p.key) }">
<label class="row-label">
{{ p.label }}
<span v-if="isModified(p.key)" class="chip-flag">已修改</span>
</label>
<div class="row-control">
<Select <Select
v-else-if="p.type === 'select'" v-if="p.type === 'select'"
:model-value="displayValue(p.key)" :model-value="displayValue(p.key)"
style="width: 260px" size="small"
transfer
@on-change="(v: string) => setValue(p.key, v)" @on-change="(v: string) => setValue(p.key, v)"
> >
<Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option> <Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select> </Select>
<!-- image:附件路径(上传后回填 /cms/upload/**) --> <ImageAssetField
<Input
v-else-if="p.type === 'image'" v-else-if="p.type === 'image'"
:model-value="displayValue(p.key)" :model-value="displayValue(p.key)"
placeholder="/cms/upload/cms/... (仅接受 /cms/upload/** 路径)" :site-id="siteId"
style="width: 320px" placeholder="/cms/upload/cms/... (上传或粘贴路径,仅接受 /cms/upload/** 与 /yschcms/upload/**)"
@on-change="(v: string) => setValue(p.key, v)" @update:model-value="(v: string) => setValue(p.key, v)"
/> />
<!-- text:短文本 -->
<Input <Input
v-else v-else
:model-value="displayValue(p.key)" :model-value="displayValue(p.key)"
size="small"
:maxlength="256" :maxlength="256"
style="width: 320px"
@on-change="(v: string) => setValue(p.key, v)" @on-change="(v: string) => setValue(p.key, v)"
/> />
<span v-if="isBad(p.key)" class="bad-mark">当前值非法({{ p.type }} 类型校验失败)</span> <Button v-if="isModified(p.key)" size="small" ghost @click="resetOne(p.key)">恢复默认</Button>
</FormItem> <span v-if="isBad(p.key)" class="bad-mark">非法值({{ p.type }} 校验失败)</span>
</Form> </div>
</div>
</div>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.empty-tip { .empty-tip {
color: #999; color: #8a93a6;
font-size: 13px; font-size: 13px;
padding: 12px 0; padding: 12px 0;
} }
.theme-param-form {
margin-top: 10px;
}
.param-head { .param-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
.param-head-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #666; color: #1e2635;
margin-bottom: 4px; }
.param-head-tip {
color: #8a93a6;
font-size: 12px;
} }
.param-warn { .param-warn {
color: #ed4014; color: #e54d42;
font-weight: 400; font-size: 12px;
margin-left: 8px; }
.reset-all {
margin-left: auto;
}
}
/* color 色卡网格:横排自动换行(150px 起步,宽屏一行 4~5 张) */
.param-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.param-chip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border: 1px solid #e9edf5;
border-radius: 8px;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #b9c8f7;
box-shadow: 0 4px 12px -8px rgba(87, 122, 251, 0.45);
}
&.bad {
border-color: #e54d42;
}
&.modified {
border-color: #ffd591;
background: #fffdf7;
}
.chip-name {
font-size: 12px;
color: #1e2635;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-hex {
font-size: 11px;
font-family: Consolas, Menlo, monospace;
color: #8a93a6;
}
.chip-reset {
align-self: flex-start;
height: 20px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
/* ColorPicker 触发器整条拉宽成色条 */
:deep(.ivu-color-picker) {
width: 100%;
display: block;
}
:deep(.ivu-color-picker .ivu-input-wrapper) {
display: block;
}
:deep(.ivu-color-picker .ivu-input) {
width: 100%;
height: 22px;
border-radius: 6px;
cursor: pointer;
}
}
/* 「已修改」小徽标:色卡与行式 label 共用 */
.chip-flag {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
border-radius: 7px;
background: #fff7e6;
color: #d46b08;
font-size: 10px;
line-height: 14px;
}
/* select/image/text:两列行式 */
.param-rows {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 8px 18px;
margin-top: 12px;
}
.param-row {
display: flex;
align-items: center;
gap: 10px;
&.bad .row-label {
color: #e54d42;
}
.row-label {
flex: none;
width: 76px;
font-size: 13px;
color: #1e2635;
text-align: right;
}
.row-control {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
.ivu-select,
.ivu-input-wrapper {
flex: 1;
min-width: 0;
}
:deep(.image-asset-field) {
flex: 1;
min-width: 0;
}
}
} }
.bad-mark { .bad-mark {
color: #ed4014; color: #e54d42;
font-size: 12px; font-size: 12px;
margin-left: 8px; white-space: nowrap;
} }
</style> </style>
+22 -3
View File
@@ -7,12 +7,17 @@ import { FormModelValue } from "@/utils/themeParamForm";
* 主题预览框(方案 §5.5/§6.3):iframe 加载 /api/theme/preview(同源 CAS 会话, * 主题预览框(方案 §5.5/§6.3):iframe 加载 /api/theme/preview(同源 CAS 会话,
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。 * no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
*/ */
const props = defineProps<{ const props = withDefaults(
defineProps<{
/** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */ /** 预览内容源站点门户路径(W4:sitePath 语义,取该站栏目/文章/轮播渲染) */
sitePath?: string; sitePath?: string;
theme?: string; theme?: string;
themeParams?: FormModelValue; themeParams?: FormModelValue;
}>(); /** 填满父容器高度(皮肤包管理全屏预览弹窗);默认固定高,向导/编辑弹窗内不变 */
fill?: boolean;
}>(),
{ fill: false }
);
const src = computed(() => { const src = computed(() => {
if (!props.sitePath || !props.theme) return ""; if (!props.sitePath || !props.theme) return "";
@@ -21,7 +26,7 @@ const src = computed(() => {
</script> </script>
<template> <template>
<div class="theme-preview"> <div class="theme-preview" :class="{ 'theme-preview--fill': fill }">
<template v-if="!sitePath || !theme"> <template v-if="!sitePath || !theme">
<div class="preview-empty"> <div class="preview-empty">
无可用内容源站点 —— 预览需要借用一个已有站点的内容 无可用内容源站点 —— 预览需要借用一个已有站点的内容
@@ -66,4 +71,18 @@ const src = computed(() => {
font-size: 12px; font-size: 12px;
} }
} }
.theme-preview--fill {
height: 100%;
display: flex;
flex-direction: column;
.preview-frame {
height: auto;
flex: 1;
min-height: 420px;
}
.preview-empty {
height: auto;
flex: 1;
}
}
</style> </style>
+4
View File
@@ -9,8 +9,12 @@ import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss"; import "@/assets/styles/main.scss";
import "@/assets/styles/layout.css"; import "@/assets/styles/layout.css";
import "@/assets/styles/icons.css"; import "@/assets/styles/icons.css";
import "@/assets/styles/yh-theme.css"; // 管理端主题层(生成物,须排在 layout/icons 之后)
import directive from "@/utils/directive/index"; import directive from "@/utils/directive/index";
import "virtual:uno.css"; import "virtual:uno.css";
import { initTheme } from "@/utils/theme";
initTheme(); // 挂载前恢复配色档位,消除首帧闪色
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
+6 -3
View File
@@ -8,17 +8,20 @@ const router = createRouter({
}); });
/** /**
* 学校上下文守卫(docs/09 §3.3): * 路由守卫(docs/09 §3.3 + 任务二超管收敛):
* - meta.superOnly 页面仅超管可入(皮肤包管理),非超管回工作台;启动引导期(user 未装载)放行由页面兜底;
* - /sch/:schCode 要求该主站编码在当前用户可管理站点内; * - /sch/:schCode 要求该主站编码在当前用户可管理站点内;
* - 深链直达且上下文未对准时,按 user.sites 自动恢复学校/站点上下文; * - 深链直达且上下文未对准时,按 user.sites 自动恢复学校/站点上下文;
* - 启动引导期(App.vue 未装配 user)放行,由 SchoolLayout 数据就绪后兜底校验;
* - 校级用户不可管理的学校一律回工作台。 * - 校级用户不可管理的学校一律回工作台。
*/ */
router.beforeEach(to => { router.beforeEach(to => {
const store = useCmsStore();
if (to.meta.superOnly && store.user && !store.isSuperAdmin) {
return "/workbench";
}
if (!to.path.startsWith("/sch/")) { if (!to.path.startsWith("/sch/")) {
return true; return true;
} }
const store = useCmsStore();
const schCode = to.params.schCode as string; const schCode = to.params.schCode as string;
if (!schCode) { if (!schCode) {
return "/workbench"; return "/workbench";
+68 -25
View File
@@ -5,7 +5,9 @@ import { useCmsStore } from "@/store";
* CMS 管理端路由(hash 模式,部署于 /yschcms/admin 子路径)。 * CMS 管理端路由(hash 模式,部署于 /yschcms/admin 子路径)。
* 学校化管理双层壳(docs/09 §3.3): * 学校化管理双层壳(docs/09 §3.3):
* 一级 /workbench 学校工作台(无壳全屏);二级 /sch/:schCode/* 学校专属管理壳(SchoolLayout)。 * 一级 /workbench 学校工作台(无壳全屏);二级 /sch/:schCode/* 学校专属管理壳(SchoolLayout)。
* 站点/学校上下文经 localStorage 持久化,各页面从 store 读取。 * 站点分层(docs/13 方案 C):主站 `/sch/{code}/{page}`;子站 `/sch/{code}/site/{siteCode}/{page}`,
* URL 为站点上下文唯一事实源。site 版路由由主站清单深拷贝去 name 生成,
* 保证 `router.push({ name })` 永远产出主站形态。
* meta.modId 与基础平台模块(GetUserMod)契约对应,供 menuRoute 解析菜单/面包屑; * meta.modId 与基础平台模块(GetUserMod)契约对应,供 menuRoute 解析菜单/面包屑;
* meta.subOf 声明「列表子页面」(面包屑/侧边栏高亮归属所属列表)。 * meta.subOf 声明「列表子页面」(面包屑/侧边栏高亮归属所属列表)。
*/ */
@@ -30,24 +32,8 @@ function legacyRedirect(sub: string) {
}; };
} }
export const routes: RouteRecordRaw[] = [ /** 学校壳内功能页(主站形态一份;meta/modId 供菜单解析与剥段高亮) */
{ const cmsChildren: RouteRecordRaw[] = [
path: "/",
redirect: "/workbench",
},
{
// 一级:学校工作台(全屏无侧边栏壳)
path: "/workbench",
name: "SchoolWorkbench",
component: () => import("@/views/cms/SchoolWorkbench.vue"),
meta: { title: "学校工作台" },
},
{
// 二级:学校专属管理壳,:schCode = 主站编码(URL 即学校上下文)
// (懒加载引入,避免 routes → SchoolLayout → menuRoute → routes 循环依赖)
path: "/sch/:schCode",
component: () => import("@/components/SchoolLayout.vue"),
children: [
{ path: "", redirect: (to: any) => `/sch/${to.params.schCode}/article` }, { path: "", redirect: (to: any) => `/sch/${to.params.schCode}/article` },
{ {
path: "article", path: "article",
@@ -81,6 +67,14 @@ export const routes: RouteRecordRaw[] = [
component: () => import("@/views/cms/BannerManage.vue"), component: () => import("@/views/cms/BannerManage.vue"),
meta: { title: "轮播管理", modId: 3 }, meta: { title: "轮播管理", modId: 3 },
}, },
{
// docs/20 D-7:名师风采数据维护(people 区块数据源)。菜单挂靠轮播管理族(平台 UserBase 未配
// 独立模块前经首页布局编辑器 people 区块属性面板直达;modId 3 = 轮播管理,面包屑归属一致)
path: "person",
name: "PersonManage",
component: () => import("@/views/cms/PersonManage.vue"),
meta: { title: "人物管理", modId: 3, subOf: "/banner" },
},
{ {
path: "link", path: "link",
name: "LinkManage", name: "LinkManage",
@@ -100,11 +94,18 @@ export const routes: RouteRecordRaw[] = [
meta: { title: "站点管理", modId: 6 }, meta: { title: "站点管理", modId: 6 },
}, },
{ {
// W1:皮肤包管理(平台模块表注册前由站点管理按钮兜底可达,R3) // 站点设置中心(docs/16 主线A):新建 = 创建模式(仅基本信息/皮肤可编辑),编辑 = 完整分区页。
path: "theme", // 列表子页面形态(subOf),面包屑/侧栏高亮归属「站点管理」
name: "ThemeManage", path: "site/new",
component: () => import("@/views/cms/ThemeManage.vue"), name: "SiteSettingNew",
meta: { title: "皮肤包管理", modId: 7 }, component: () => import("@/views/cms/SiteSetting.vue"),
meta: { title: "新建站点", modId: 6, subOf: "/site" },
},
{
path: "site/setting/:id",
name: "SiteSetting",
component: () => import("@/views/cms/SiteSetting.vue"),
meta: { title: "站点设置", modId: 6, subOf: "/site" },
}, },
{ {
// 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供) // 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供)
@@ -113,6 +114,46 @@ export const routes: RouteRecordRaw[] = [
component: () => import("@/views/cms/PortalPreview.vue"), component: () => import("@/views/cms/PortalPreview.vue"),
meta: { title: "门户预览", modId: 99 }, meta: { title: "门户预览", modId: 99 },
}, },
];
/** 子站形态:同一批页面,去掉 name(防重复注册告警;子站导航一律走 path 拼装) */
const siteChildren: RouteRecordRaw[] = cmsChildren.map(r => ({ ...r, name: undefined }));
export const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: "/workbench",
},
{
// 一级:学校工作台(全屏无侧边栏壳)
path: "/workbench",
name: "SchoolWorkbench",
component: () => import("@/views/cms/SchoolWorkbench.vue"),
meta: { title: "学校工作台" },
},
{
// 平台级:皮肤包管理(任务二收敛:仅超管,工作台按钮进入,不挂左侧菜单)
path: "/theme-manage",
name: "ThemePackageManage",
component: () => import("@/views/cms/ThemeManage.vue"),
meta: { title: "皮肤包管理", superOnly: true },
},
{
// 二级:学校专属管理壳,:schCode = 主站编码(URL 即学校上下文)
// (懒加载引入,避免 routes → SchoolLayout → menuRoute → routes 循环依赖)
path: "/sch/:schCode",
component: () => import("@/components/SchoolLayout.vue"),
children: [
...cmsChildren,
{
// 站点段前缀层(docs/13 §2):无 component,子记录直接渲染到 SchoolLayout 的 router-view;
// 站点上下文由 SchoolLayout.syncSiteFromRoute 按此段同步 store
path: "site/:siteCode",
children: [
{ path: "", redirect: (to: any) => `/sch/${to.params.schCode}/site/${to.params.siteCode}/article` },
...siteChildren,
],
},
], ],
}, },
// ===== 旧路径兼容:重定向到当前学校上下文,不 404 ===== // ===== 旧路径兼容:重定向到当前学校上下文,不 404 =====
@@ -123,7 +164,9 @@ export const routes: RouteRecordRaw[] = [
{ path: "/link", redirect: legacyRedirect("link") }, { path: "/link", redirect: legacyRedirect("link") },
{ path: "/attachment", redirect: legacyRedirect("attachment") }, { path: "/attachment", redirect: legacyRedirect("attachment") },
{ path: "/site", redirect: legacyRedirect("site") }, { path: "/site", redirect: legacyRedirect("site") },
{ path: "/theme", redirect: legacyRedirect("theme") }, { path: "/site/new", redirect: legacyRedirect("site/new") },
{ path: "/site/setting/:id", redirect: legacyRedirect("site/setting") },
{ path: "/theme", redirect: "/theme-manage" },
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") }, { path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
+11 -6
View File
@@ -33,6 +33,15 @@ export const useCmsStore = defineStore("cmsStore", {
s => s.id === school.mainSiteId || s.parentSiteId === school.mainSiteId s => s.id === school.mainSiteId || s.parentSiteId === school.mainSiteId
); );
}, },
/** 当前落点是否院系子站(docs/13:主站 = 学校主站 id)——决定 URL 是否带 /site 段 */
isSubSite: (state): boolean =>
!!state.currentSite && !!state.currentSchool && state.currentSite.id !== state.currentSchool.mainSiteId,
/** adminPathOf 用的站点段 code:子站=站点 code;主站=null(省略段,URL 全兼容) */
siteSegCode(state): string | null {
return state.currentSite && state.currentSchool && state.currentSite.id !== state.currentSchool.mainSiteId
? state.currentSite.code
: null;
},
}, },
actions: { actions: {
setUser(user: AuthUserInfo | null) { setUser(user: AuthUserInfo | null) {
@@ -84,14 +93,10 @@ export const useCmsStore = defineStore("cmsStore", {
} }
return this.currentSite; return this.currentSite;
}, },
/** 进入学校上下文:校卡 + 默认站点(记住的站点仍属本校则沿用,否则主站) */ /** 进入学校上下文:校卡 + 主站点(docs/13:站点上下文由 URL 决定,
* URL 没写站点段就是主站;localStorage 记忆仅作刷新首帧兜底,不作导航决策) */
enterSchool(school: CurrentSchool, sites: { id: number; code: string; path?: string; name: string; parentSiteId?: number }[]) { enterSchool(school: CurrentSchool, sites: { id: number; code: string; path?: string; name: string; parentSiteId?: number }[]) {
this.setCurrentSchool(school); this.setCurrentSchool(school);
const remembered = this.currentSite;
const belongs = remembered && sites.some(s => s.id === remembered.id);
if (belongs) {
return;
}
const main = sites.find(s => s.id === school.mainSiteId) || sites[0]; const main = sites.find(s => s.id === school.mainSiteId) || sites[0];
if (main) { if (main) {
this.setCurrentSite({ id: main.id, code: main.code, path: main.path || main.code, name: main.name }); this.setCurrentSite({ id: main.id, code: main.code, path: main.path || main.code, name: main.name });
+46 -26
View File
@@ -95,6 +95,8 @@ export interface CategoryTreeNode {
type: number; type: number;
linkUrl?: string; linkUrl?: string;
template?: string; template?: string;
/** 首页展示形态(docs/12:空=默认卡 / news_grid 双栏新闻卡 / media_focus 来源标签列表) */
homeStyle?: string;
/** 单页内容(type=2) */ /** 单页内容(type=2) */
content?: string; content?: string;
sortNo: number; sortNo: number;
@@ -167,7 +169,8 @@ export interface ArticleSaveRequest {
/** 文章分页查询(ArticlePageQuery) */ /** 文章分页查询(ArticlePageQuery) */
export interface ArticlePageQuery { export interface ArticlePageQuery {
siteId: number; siteId: number;
categoryId?: number; /** 栏目 id(后端字段 categoryIds: List<Integer>,多选逗号分隔;空=全部栏目) */
categoryIds?: number | string;
status?: number; status?: number;
keyword?: string; keyword?: string;
page?: number; page?: number;
@@ -208,6 +211,17 @@ export interface BannerVo {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
status: number; status: number;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
/** 专题大卡描述(position=topic) */
description?: string;
/** 专题大卡 CTA 按钮文字 */
ctaText?: string;
/** 专题大卡 CTA 跳转地址 */
ctaUrl?: string;
/** 活动日期(position=event,yyyy-MM-dd) */
eventDate?: string;
/** 活动地点(position=event) */
location?: string;
} }
/* ===== 友链 ===== */ /* ===== 友链 ===== */
@@ -224,42 +238,30 @@ export interface LinkVo {
status: number; status: number;
} }
/* ===== 通用 ===== */ /* ===== 皮肤包(ThemeVo,S1 交接契约:参数值为包级)===== */
/** 删除入参(DeleteRequest) */ /** select 型参数的候选项 */
export interface DeleteRequest {
id: number;
siteId: number;
}
/** 后端分页包装 {total, list} */
export interface PageResult<T> {
total: number;
list: T[];
}
/* ===== 主题 ===== */
/** theme.json 可调参数选项(select 类型) */
export interface ThemeParamOption { export interface ThemeParamOption {
value: string; value: string;
label: string; label: string;
} }
/** 主题包参数 schema(ThemeParam) */ /** 参数 schema 项(后端 ThemeVo.ThemeParamVo):schema 只来自包内 theme.json */
export interface ThemeParamVo { export interface ThemeParamVo {
key: string; key: string;
/** 聚合进 :root{} 的 CSS 变量名(仅 color 类型有) */ /** color 型才有,CSS 变量名(如 --primary) */
cssVar?: string; cssVar?: string;
label: string; label: string;
/** color / image / select / text */ /** color | select | image | text */
type: string; type: string;
/** 包内默认值:前端「已修改」标记与恢复默认的基准 */
defaultValue?: string; defaultValue?: string;
options?: ThemeParamOption[]; options?: ThemeParamOption[];
} }
/** 主题包(ThemeVo,GET /api/theme/list) */ /** 皮肤包(GET/PUT /api/theme/**) */
export interface ThemeVo { export interface ThemeVo {
/** 标识名 = 主题路径段,创建后不可改 */
name: string; name: string;
title: string; title: string;
description?: string; description?: string;
@@ -267,14 +269,32 @@ export interface ThemeVo {
author?: string; author?: string;
bannerSize?: string; bannerSize?: string;
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */ /** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
screenshotUrl: string; screenshotUrl?: string;
params: ThemeParamVo[]; params: ThemeParamVo[];
/** 包来源(W1):builtin=内置只读 / external=外部可管 */ /** 包级参数值(工作项 T):只含被微调过的 key,存 themes/.meta/{name}.json;缺项按 defaultValue 回落 */
paramValues?: Record<string, string>;
/** builtin=内置(模板/资产只读)/ external=外部可管 */
source?: string; source?: string;
/** 被用站点数(W1,删除保护口径与后端一致) */
inUseCount?: number; inUseCount?: number;
/** 是否可编辑/删除(W1:仅外部包) */
editable?: boolean; editable?: boolean;
/** 引用站点名列表(W1:删除保护提示/tooltip) */
usedBySiteNames?: string[]; usedBySiteNames?: string[];
} }
/* ===== 事项库(svc,事项库详细设计 §7.4)===== */
/* ===== 名师风采(docs/20 D-7,PersonVo) ===== */
export interface PersonVo {
id?: number;
siteId: number;
name: string;
/** 头衔/职称 */
title?: string;
/** 一句话简介 */
bio?: string;
/** 头像 URL(空 = 首字徽章兜底) */
avatar?: string;
/** 所属栏目 id(空 = 未分组) */
categoryId?: number;
sortNo: number;
status: number;
}
+21 -2
View File
@@ -1,5 +1,5 @@
import { IObj, UseTableSetting } from "@/types/Basics"; import { IObj, UseTableSetting } from "@/types/Basics";
import { resolveComponent } from "vue"; import { resolveComponent, ref, onScopeDispose, getCurrentScope } from "vue";
export default class Utils { export default class Utils {
/** /**
* 格式化日期 * 格式化日期
@@ -128,7 +128,11 @@ export default class Utils {
} }
/** /**
* 计算表格高度 * 表格高度(响应式):返回 Ref<number>,模板绑定自动解包。
* svc-hall 走查教训——一次性快照在窗口缩放/滚动条出现后冻结,
* 这里挂 ResizeObserver(body) 重算;无 RO 环境优雅降级为静态值。
* 常量沿用 56/56 档(顶栏 56 + 面包屑 56 = 112,见《管理端整体主题迁移方案》附录 A,
* P2 紧凑化时集中改这里)。
*/ */
public static useTableHeight({ public static useTableHeight({
isSearch = false, isSearch = false,
@@ -137,6 +141,7 @@ export default class Utils {
isOperat = false, isOperat = false,
otherH = 0, otherH = 0,
} = {}) { } = {}) {
const calc = () => {
let bodyH = document.body.clientHeight, let bodyH = document.body.clientHeight,
headerH = 112, headerH = 112,
searchH = 52, searchH = 52,
@@ -159,6 +164,20 @@ export default class Utils {
isPage && (tableH -= pageH); isPage && (tableH -= pageH);
otherH > 0 && (tableH -= otherH); otherH > 0 && (tableH -= otherH);
return tableH; return tableH;
};
const h = ref(calc());
if (typeof ResizeObserver === "function" && document?.body) {
let raf = 0;
const ro = new ResizeObserver(() => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => (h.value = calc()));
});
ro.observe(document.body);
// 作用域内创建则随作用域释放;组件外裸调用不挂(避免无主观察器泄漏告警)
if (getCurrentScope()) onScopeDispose(() => ro.disconnect());
}
return h;
} }
/** /**
* 数字转大写汉字 * 数字转大写汉字
+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/store", () => ({
useCmsStore: () => ({ schoolCode: "demo" }),
}));
import { resolveMenuPage } from "./menuRoute";
describe("resolveMenuPage 学校壳具体路径匹配", () => {
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
it("/sch/{code}/article 直接命中,不回退名称识别、不告警", () => {
expect(resolveMenuPage("/sch/demo/article", null, "文章列表")).toEqual({
path: "/sch/demo/article",
external: false,
});
expect(warn).not.toHaveBeenCalled();
});
it("他校编码的壳路径重映射到当前学校上下文", () => {
expect(resolveMenuPage("/sch/fsz/category", null, "栏目管理")).toEqual({
path: "/sch/demo/category",
external: false,
});
expect(warn).not.toHaveBeenCalled();
});
it("裸子路径 /article 仍命中学校壳路由", () => {
expect(resolveMenuPage("/article", null, "文章列表")).toEqual({
path: "/sch/demo/article",
external: false,
});
expect(warn).not.toHaveBeenCalled();
});
it("顶层绝对路由不受影响(/workbench)", () => {
expect(resolveMenuPage("/workbench", null, "工作台")).toEqual({
path: "/workbench",
external: false,
});
expect(warn).not.toHaveBeenCalled();
});
it("配错路径仍走名称回退(行为保持)", () => {
expect(resolveMenuPage("/no-such-page", null, "友情链接")).toEqual({
path: "/sch/demo/link",
external: false,
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
+10 -1
View File
@@ -34,6 +34,11 @@ const ROUTE_ENTRIES: RouteEntry[] = [];
if (raw.includes(":") && !isSchoolRoot && !underSchool) { if (raw.includes(":") && !isSchoolRoot && !underSchool) {
return; return;
} }
// 站点段前缀层(docs/13):无 component 的参数化嵌套层,其子记录只是主站清单的镜像。
// 纳入清单会让名称/modId 匹配出现重影(双向包含匹配命中多个即放弃),故整枝跳过
if (raw.includes(":") && !isSchoolRoot && !r.component && !r.redirect) {
return;
}
const nextUnder = underSchool || isSchoolRoot; const nextUnder = underSchool || isSchoolRoot;
const sub = nextUnder && !raw.startsWith("/") ? "/" + raw : raw; const sub = nextUnder && !raw.startsWith("/") ? "/" + raw : raw;
if (!r.redirect && r.component) { if (!r.redirect && r.component) {
@@ -114,7 +119,11 @@ function matchRoute(candidate: string): string | null {
// 2. 学校壳内子路径匹配(/article → /sch/{code}/article),兼容新旧上下文前缀(历史 /cms、现网 /yschcms) // 2. 学校壳内子路径匹配(/article → /sch/{code}/article),兼容新旧上下文前缀(历史 /cms、现网 /yschcms)
const stripped = lower.startsWith("/cms/") ? lower.slice("/cms".length) const stripped = lower.startsWith("/cms/") ? lower.slice("/cms".length)
: lower.startsWith("/yschcms/") ? lower.slice("/yschcms".length) : lower; : lower.startsWith("/yschcms/") ? lower.slice("/yschcms".length) : lower;
const entry = ROUTE_ENTRIES.find(e => !e.absolute && e.sub.toLowerCase() === stripped); // 平台侧已配成具体壳路径(/sch/{任意编码}/article):剥壳后按子路径命中,
// 运行时经 toConcrete 重映射到当前学校上下文(他校编码不直接透传)
const deshelled = /^\/sch\/[^/]+(?=\/)/.test(stripped) ? stripped.replace(/^\/sch\/[^/]+/, "") : stripped;
const entry = ROUTE_ENTRIES.find(e => !e.absolute &&
(e.sub.toLowerCase() === stripped || e.sub.toLowerCase() === deshelled));
if (entry) { if (entry) {
return toConcrete(entry); return toConcrete(entry);
} }
+44 -41
View File
@@ -1,17 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
EXTRA_KEY_RE,
SITE_CONFIG_FIELD_DEFS, SITE_CONFIG_FIELD_DEFS,
extraRowError,
firstExtraError,
parseSiteConfig, parseSiteConfig,
serializeSiteConfig, serializeSiteConfig,
validateFieldValue, validateFieldValue,
} from "./siteConfigForm"; } from "./siteConfigForm";
/** docs/09 §3.1 站点配置结构化:parse/serialize/校验 */ /** docs/09 §3.1 站点配置结构化:parse/serialize/校验;docs/20 F6 扩展参数区退役(未知键静默保留) */
describe("siteConfigForm", () => { describe("siteConfigForm", () => {
it("parse:已知键入 fields、未知字符串键入扩展行、theme_params/对象键无损入 preserved", () => { it("parse:已知键入 fields、未知键(字符串/对象)与系统托管键均无损入 preserved(F6)", () => {
const json = JSON.stringify({ const json = JSON.stringify({
logo: "/cms/upload/logo.png", logo: "/cms/upload/logo.png",
contact: "电话 0571-88888888", contact: "电话 0571-88888888",
@@ -26,14 +23,14 @@ describe("siteConfigForm", () => {
expect(m.fields.contact).toBe("电话 0571-88888888"); expect(m.fields.contact).toBe("电话 0571-88888888");
expect(m.fields.domain).toBe("www.xxx.edu.cn"); expect(m.fields.domain).toBe("www.xxx.edu.cn");
expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn"); expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn");
expect(m.extras).toEqual([{ key: "my_ext", value: "自定义值" }]); expect(m.preserved.my_ext).toBe("自定义值"); // F6:未知字符串键不再进扩展行,静默保留
expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" }); expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" });
expect(m.preserved.wechat).toEqual({ appid: "wx123" }); expect(m.preserved.wechat).toEqual({ appid: "wx123" });
}); });
it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => { it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => {
expect(parseSiteConfig("这不是json{").fields).toEqual({}); expect(parseSiteConfig("这不是json{").fields).toEqual({});
expect(parseSiteConfig(null).extras).toEqual([]); expect(parseSiteConfig(null).preserved).toEqual({});
expect(parseSiteConfig("").preserved).toEqual({}); expect(parseSiteConfig("").preserved).toEqual({});
const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] })); const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] }));
expect(m.fields.logo).toBeUndefined(); expect(m.fields.logo).toBeUndefined();
@@ -41,22 +38,17 @@ describe("siteConfigForm", () => {
expect(m.preserved.other).toEqual([1, 2]); expect(m.preserved.other).toEqual([1, 2]);
}); });
it("serialize:空值剔除、扩展行空键/空值剔除、preserved 永不丢失", () => { it("serialize:空值剔除、preserved 永不丢失", () => {
const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } })); const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } }));
m.fields.contact = " "; // 空白 → 剔除 m.fields.contact = " "; // 空白 → 剔除
m.extras.push({ key: "k1", value: "v1" });
m.extras.push({ key: "", value: "被剔除" });
m.extras.push({ key: "k2", value: " " }); // 空值 → 剔除
const out = JSON.parse(serializeSiteConfig(m)); const out = JSON.parse(serializeSiteConfig(m));
expect(out.logo).toBe("/cms/upload/logo.png"); expect(out.logo).toBe("/cms/upload/logo.png");
expect(out).not.toHaveProperty("contact"); expect(out).not.toHaveProperty("contact");
expect(out.k1).toBe("v1");
expect(out).not.toHaveProperty("k2");
expect(out.theme_params).toEqual({ primary: "#fff" }); expect(out.theme_params).toEqual({ primary: "#fff" });
expect(out.wechat).toEqual({ x: 1 }); expect(out.wechat).toEqual({ x: 1 });
}); });
it("roundtrip:parse→serialize→parse 内容不丢(含扩展行与 preserved)", () => { it("roundtrip:parse→serialize→parse 内容不丢(未知键与 preserved 全保留)", () => {
const json = JSON.stringify({ const json = JSON.stringify({
copyright: "XX学校 · 版权所有 © 2026", copyright: "XX学校 · 版权所有 © 2026",
seo_description: "校讯", seo_description: "校讯",
@@ -68,12 +60,12 @@ describe("siteConfigForm", () => {
const twice = parseSiteConfig(serializeSiteConfig(once)); const twice = parseSiteConfig(serializeSiteConfig(once));
expect(twice.fields.slogan).toBe("厚德博学"); expect(twice.fields.slogan).toBe("厚德博学");
expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026"); expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026");
expect(twice.extras).toEqual([{ key: "my_ext", value: "v" }]); expect(twice.preserved.my_ext).toBe("v");
expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" }); expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" });
}); });
it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => { it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => {
const def = (kind: string, maxLength?: number) => const def = (kind: string) =>
SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!; SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!;
expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe(""); expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe("");
expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http"); expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http");
@@ -87,38 +79,49 @@ describe("siteConfigForm", () => {
expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填) expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填)
}); });
it("extraRowError:格式/重名/重复校验;空键行放行", () => { it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => {
const rows = [{ key: "my_ext", value: "v" }];
expect(extraRowError(rows, 0)).toBe("");
expect(extraRowError([{ key: "", value: "v" }], 0)).toBe("");
expect(extraRowError([{ key: "my-ext", value: "v" }], 0)).toContain("下划线");
expect(extraRowError([{ key: "1bad", value: "v" }], 0)).toContain("下划线");
expect(extraRowError([{ key: "logo", value: "v" }], 0)).toContain("重名");
expect(extraRowError([{ key: "theme_params", value: "v" }], 0)).toContain("重名");
expect(extraRowError([{ key: "a", value: "1" }, { key: "a", value: "2" }], 1)).toContain("重复");
expect(EXTRA_KEY_RE.test("wechat_appid")).toBe(true);
expect(EXTRA_KEY_RE.test("_priv")).toBe(true);
expect(EXTRA_KEY_RE.test("9bad")).toBe(false);
});
it("firstExtraError:返回第一条错误;全部合法返回空串", () => {
expect(firstExtraError([{ key: "ok_key", value: "1" }, { key: "bad-key", value: "2" }])).toContain("下划线");
expect(firstExtraError([{ key: "a", value: "1" }, { key: "b", value: "2" }])).toBe("");
expect(firstExtraError([])).toBe("");
});
it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => {
expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([ expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([
"logo", "favicon", "slogan", "colleges_label", "logo", "favicon", "slogan", "colleges_label",
"contact", "copyright", "beian", "contact", "footer_addresses", "qr_wechat", "qr_weibo",
"social_wechat", "social_weibo", "social_douyin", "social_bilibili",
"copyright", "beian",
"seo_description", "seo_description",
"domain", "public_base_url", "domain", "public_base_url", "svc_hall_base",
]); ]);
}); });
it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => { it("docs/12:页脚增强新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
const json = JSON.stringify({
footer_addresses: "潭山校区:南湖路 237 号 邮编:464000\n淮河校区:大学路 1 号 邮编:464300",
qr_wechat: "/cms/upload/qr-wechat.png",
social_weibo: "https://weibo.com/xxx",
social_douyin: "not-a-url",
});
const m = parseSiteConfig(json);
expect(m.fields.footer_addresses).toContain("淮河校区");
expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png");
expect(m.fields.social_weibo).toBe("https://weibo.com/xxx");
const qrDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "qr_weibo")!;
const socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!;
expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径");
expect(validateFieldValue(socialDef, "not-a-url")).toContain("http");
// 空值统一剔除:不污染 config_json
m.fields.social_bilibili = " ";
const out = JSON.parse(serializeSiteConfig(m));
expect(out.footer_addresses).toContain("邮编:464300");
expect(out).not.toHaveProperty("social_bilibili");
});
it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(无损回写)", () => {
const json = JSON.stringify({ gallery_title: "线上展厅", news_bg_image: "/cms/upload/campus.jpg" });
const m = parseSiteConfig(json);
expect(m.fields).toEqual({}); // 不进任何分区表单
expect(m.preserved.gallery_title).toBe("线上展厅");
expect(m.preserved.news_bg_image).toBe("/cms/upload/campus.jpg");
expect(JSON.parse(serializeSiteConfig(m))).toEqual(JSON.parse(json)); // 存量值无损保留
});
it("B3:custom_css 为保留键,parse/serialize 无损往返", () => {
const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" }); const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" });
const m = parseSiteConfig(json); const m = parseSiteConfig(json);
expect(m.extras).toEqual([{ key: "my_ext", value: "v" }]); expect(m.preserved.my_ext).toBe("v");
expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}"); expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}");
expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}"); expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}");
}); });
+91 -62
View File
@@ -1,7 +1,8 @@
/** /**
* 站点配置结构化表单纯函数模块(docs/09 §3.1)。 * 站点配置结构化表单纯函数模块(docs/09 §3.1;docs/20 F6 扩展参数分区退役)。
* 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行 * 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);
* (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。 * 未知键(字符串与非字符串)一律无损入 preserved 静默保留——不再提供「扩展参数」编辑区
* (后端对未知键的宽容策略不变:不拦截、不渲染、不丢失)。
* vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。 * vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。
*/ */
@@ -16,6 +17,8 @@ export interface SiteConfigFieldDef {
multiline?: boolean; multiline?: boolean;
/** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */ /** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */
kind?: "text" | "url" | "host" | "asset"; kind?: "text" | "url" | "host" | "asset";
/** F5(docs/20):asset 类字段的预览/占位框比例(favicon 1:1、二维码 4:3) */
aspect?: "1:1" | "4:3";
maxLength?: number; maxLength?: number;
} }
@@ -27,8 +30,9 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。", tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。",
}, },
{ {
key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, aspect: "1:1",
placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)", placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)",
tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。",
}, },
{ {
key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200, key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200,
@@ -42,14 +46,52 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
{ {
key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true, key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true,
placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号", placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号",
tip: "显示在页脚品牌区(页脚第一行位置)。",
},
{
key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true,
placeholder: "一行一个校区,如:\nXX校区:杭州市 XX 路 100 号 邮编:310000",
tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。",
},
{
key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)",
tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。",
},
{
key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3",
placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)",
tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。",
},
{
key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500,
placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500,
placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500,
placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
},
{
key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500,
placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)",
tip: "页脚社媒图标行(点击跳转链接地址)。",
}, },
{ {
key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200, key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200,
placeholder: "如:XX学校 · 版权所有 © 2026", placeholder: "如:XX学校 · 版权所有 © 2026",
tip: "页脚最底行的版权声明文字。",
}, },
{ {
key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100, key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100,
placeholder: "如:浙ICP备XXXXXXXX号-1", placeholder: "如:浙ICP备XXXXXXXX号-1",
tip: "页脚最底行版权文字旁的 ICP 备案号(可点击性质由模板决定,通常为纯文字)。",
}, },
{ {
key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true, key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true,
@@ -65,6 +107,11 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
placeholder: "如 https://www.xxx.edu.cn", placeholder: "如 https://www.xxx.edu.cn",
tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/yschcms/portal/{站点编码})。", tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/yschcms/portal/{站点编码})。",
}, },
{
key: "svc_hall_base", label: "服务大厅基地址", group: "domain", kind: "url", maxLength: 500,
placeholder: "如 https://svhall.xxx.edu.cn(留空不生成「我要办理」链接)",
tip: "事项指南页「我要办理」按钮:事项单独配置的外链优先;未单独配置时用本地址生成服务大厅发起页(/#/apply?siteCode=…&itemCode=…)。",
},
]; ];
export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [ export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
@@ -74,31 +121,34 @@ export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
{ name: "domain", title: "域名与访问" }, { name: "domain", title: "域名与访问" },
]; ];
/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */ /**
* 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护;
* custom_css 由站点设置「皮肤与外观」分区维护)。
*/
export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"]; export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"];
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */ /**
* docs/16 D4 被吸收的旧键:画廊标题/副题、新闻卡背景图改由首页布局区块属性承载(home_layout),
* 分区表单不再展示;存量值静默保留(渲染端仍作区块未设 title/background 时的默认值)。
*/
export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"];
/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致;供后续功能键落位时复用) */
export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/; export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
export const URL_RE = /^https?:\/\/\S+$/; export const URL_RE = /^https?:\/\/\S+$/;
export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/; export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/;
export interface SiteConfigExtraRow {
key: string;
value: string;
}
export interface SiteConfigFormModel { export interface SiteConfigFormModel {
fields: Record<string, string>; fields: Record<string, string>;
extras: SiteConfigExtraRow[]; /** 非已知键(含系统托管键与未知键)无损保留 */
/** 系统托管键 + 非字符串未知键,无损保留 */
preserved: Record<string, unknown>; preserved: Record<string, unknown>;
} }
/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */ /** configJson → 表单模型:已知键入 fields,其余(系统托管键/旧键/未知键)无损入 preserved */
export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel { export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel {
const model: SiteConfigFormModel = { fields: {}, extras: [], preserved: {} }; const model: SiteConfigFormModel = { fields: {}, preserved: {} };
if (!configJson || !configJson.trim()) { if (!configJson || !configJson.trim()) {
return model; return model;
} }
@@ -115,23 +165,18 @@ export function parseSiteConfig(configJson: string | undefined | null): SiteConf
return model; return model;
} }
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key)); const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
const reserved = new Set(RESERVED_SITE_CONFIG_KEYS);
Object.keys(parsed).forEach(k => { Object.keys(parsed).forEach(k => {
const v = parsed![k]; const v = parsed![k];
if (typeof v !== "string") { if (known.has(k) && typeof v === "string") {
model.preserved[k] = v;
} else if (known.has(k)) {
model.fields[k] = v; model.fields[k] = v;
} else if (reserved.has(k)) {
model.preserved[k] = v;
} else { } else {
model.extras.push({ key: k, value: v }); model.preserved[k] = v;
} }
}); });
return model; return model;
} }
/** 表单模型 → configJson:空值剔除;扩展行空键/空值剔除;preserved 原样回写(永不丢失) */ /** 表单模型 → configJson:空值剔除;preserved 原样回写(永不丢失) */
export function serializeSiteConfig(model: SiteConfigFormModel): string { export function serializeSiteConfig(model: SiteConfigFormModel): string {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
SITE_CONFIG_FIELD_DEFS.forEach(def => { SITE_CONFIG_FIELD_DEFS.forEach(def => {
@@ -140,19 +185,35 @@ export function serializeSiteConfig(model: SiteConfigFormModel): string {
out[def.key] = v; out[def.key] = v;
} }
}); });
(model.extras || []).forEach(row => {
const k = (row.key || "").trim();
const v = (row.value || "").trim();
if (k && v) {
out[k] = v;
}
});
Object.keys(model.preserved || {}).forEach(k => { Object.keys(model.preserved || {}).forEach(k => {
out[k] = model.preserved[k]; out[k] = model.preserved[k];
}); });
return JSON.stringify(out); return JSON.stringify(out);
} }
/**
* F1(docs/20)Tab 独立保存:服务端事实源 base + 本 Tab 增量 patch 合并——
* patch 值为空串/null/undefined = 删除该键;其余键原样保留。
* 各 Tab 只提交自己的键空间,其他 Tab 未保存的内存修改不会被连带提交。
*/
export function mergeConfigPatch(base: string | undefined | null, patch: Record<string, unknown>): string {
let obj: Record<string, unknown>;
try {
const parsed = base && base.trim() ? JSON.parse(base) : {};
obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
} catch {
obj = {};
}
Object.entries(patch).forEach(([k, v]) => {
if (v === undefined || v === null || (typeof v === "string" && !v.trim())) {
delete obj[k];
} else {
obj[k] = v;
}
});
return JSON.stringify(obj);
}
/** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */ /** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */
export function validateFieldValue(def: SiteConfigFieldDef, value: string): string { export function validateFieldValue(def: SiteConfigFieldDef, value: string): string {
const v = (value || "").trim(); const v = (value || "").trim();
@@ -170,35 +231,3 @@ export function validateFieldValue(def: SiteConfigFieldDef, value: string): stri
return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : ""; return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : "";
} }
} }
/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */
export function extraRowError(rows: SiteConfigExtraRow[], index: number): string {
const row = rows[index];
const key = (row?.key || "").trim();
if (!key) {
return "";
}
if (!EXTRA_KEY_RE.test(key)) {
return "键名仅限字母/数字/下划线,且以字母或下划线开头";
}
const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key));
if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) {
return "扩展键不能与内置键/系统键重名";
}
const dup = rows.findIndex(r => (r.key || "").trim() === key);
if (dup >= 0 && dup !== index) {
return "扩展键重复";
}
return "";
}
/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */
export function firstExtraError(rows: SiteConfigExtraRow[]): string {
for (let i = 0; i < (rows || []).length; i++) {
const msg = extraRowError(rows, i);
if (msg) {
return msg;
}
}
return "";
}
+71 -1
View File
@@ -4,7 +4,7 @@
* previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。 * previewPortalUrl:上级 path + 编码 → 子站门户地址(parentPath 空 → 单段)。
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { portalUrlOf, previewPortalUrl } from "@/utils/sitePath"; import { portalUrlOf, previewPortalUrl, adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
describe("portalUrlOf(T-W4-16)", () => { describe("portalUrlOf(T-W4-16)", () => {
it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => { it("path 优先:嵌套 path 拼单段门户地址 + 尾斜杠", () => {
@@ -27,3 +27,73 @@ describe("previewPortalUrl(T-W4-16)", () => {
expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/"); expect(previewPortalUrl(undefined, "sch-9")).toBe("/portal/sch-9/");
}); });
}); });
/* ===== docs/13 方案 C:管理端 URL 站点段 ===== */
describe("adminPathOf(T-D13-01)", () => {
it("主站(siteCode 为空/null/等于学校编码)省略站点段——旧链全兼容", () => {
expect(adminPathOf("demo", null, "article")).toBe("/sch/demo/article");
expect(adminPathOf("demo", "", "article/edit/3")).toBe("/sch/demo/article/edit/3");
expect(adminPathOf("demo", "demo", "site")).toBe("/sch/demo/site");
});
it("院系子站插入 /site/{code} 中缀", () => {
expect(adminPathOf("demo", "jdxy", "article")).toBe("/sch/demo/site/jdxy/article");
expect(adminPathOf("demo", "jdxy", "/category")).toBe("/sch/demo/site/jdxy/category");
expect(adminPathOf("demo", "jdxy", "article/edit/9")).toBe("/sch/demo/site/jdxy/article/edit/9");
});
it("空白容错:schoolCode/siteCode 首尾空白 trimmed 后再拼(docs/15 T2)", () => {
expect(adminPathOf("demo", " jdxy ", "article")).toBe("/sch/demo/site/jdxy/article");
expect(adminPathOf(" demo ", null, "article")).toBe("/sch/demo/article");
});
});
describe("stripSiteSeg(T-D13-02)", () => {
it("剥掉 /site/{code} 中缀,回到菜单匹配用的主站形态", () => {
expect(stripSiteSeg("/sch/demo/site/jdxy/article")).toBe("/sch/demo/article");
expect(stripSiteSeg("/sch/demo/site/jdxy/article/edit/9")).toBe("/sch/demo/article/edit/9");
expect(stripSiteSeg("/sch/demo/site/jdxy")).toBe("/sch/demo");
});
it("主站形态原样返回(幂等)", () => {
expect(stripSiteSeg("/sch/demo/article")).toBe("/sch/demo/article");
expect(stripSiteSeg(stripSiteSeg("/sch/demo/site/jdxy/category"))).toBe("/sch/demo/category");
});
});
describe("schoolSubPath(T-D13-03)", () => {
it("取学校壳内子路径(主站/子站形态一致)", () => {
expect(schoolSubPath("/sch/demo/article")).toBe("/article");
expect(schoolSubPath("/sch/demo/site/jdxy/article/edit/9")).toBe("/article/edit/9");
expect(schoolSubPath("/sch/demo")).toBe("");
});
it("非学校壳路径原样返回", () => {
expect(schoolSubPath("/workbench")).toBe("/workbench");
});
});
describe("resolveRouteSite(T-D13-04:URL 段 → 目标站点)", () => {
const sites = [
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0 },
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1 },
{ id: 3, code: "wtxy", name: "文学院", parentSiteId: 1 },
];
it("无 siteCode(主站形态)→ 主站", () => {
expect(resolveRouteSite(sites, 1, undefined)).toEqual({ site: sites[0], invalid: false });
expect(resolveRouteSite(sites, 1, "")).toEqual({ site: sites[0], invalid: false });
});
it("站点段命中本校子站 → 该子站", () => {
expect(resolveRouteSite(sites, 1, "jdxy")).toEqual({ site: sites[1], invalid: false });
});
it("不存在 / 跨校 / 把主站编码当子站段 → invalid 并回主站", () => {
expect(resolveRouteSite(sites, 1, "nope")).toEqual({ site: sites[0], invalid: true });
expect(resolveRouteSite(sites, 1, "demo")).toEqual({ site: sites[0], invalid: true });
const other = [...sites, { id: 9, code: "jdxy2", name: "他校子站", parentSiteId: 8 }];
expect(resolveRouteSite(other, 1, "jdxy2")).toEqual({ site: sites[0], invalid: true });
});
});
+54
View File
@@ -15,3 +15,57 @@ export function previewPortalUrl(parentPath: string | undefined | null, code: st
const parent = parentPath && parentPath.trim() ? parentPath.trim() : ""; const parent = parentPath && parentPath.trim() ? parentPath.trim() : "";
return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`; return parent ? `/portal/${parent}/${code}/` : `/portal/${code}/`;
} }
/* ===== 管理端路由站点段(docs/13:URL 为站点上下文唯一事实源) ===== */
/**
* 管理端路由 path 唯一拼装入口:主站省略站点段(旧链全兼容),
* 子站插入 `/site/{siteCode}` 中缀。siteCode 传 null/空/等于学校主站编码时不带段。
* 全站硬拼 `/sch/...` 的跳转都应经此函数,防主站/子站形态混写。
*/
export function adminPathOf(schCode: string, siteCode: string | null | undefined, sub: string): string {
const code = String(schCode ?? "").trim();
const tail = String(sub ?? "").replace(/^\/+/, "");
const seg = String(siteCode ?? "").trim();
const isSub = !!seg && seg !== code;
return isSub ? `/sch/${code}/site/${seg}/${tail}` : `/sch/${code}/${tail}`;
}
/** 剥掉 URL 中的 `/site/{code}` 中缀(菜单高亮"剥段匹配"与面包屑取页面子路径用) */
export function stripSiteSeg(p: string): string {
return String(p ?? "").replace(/\/site\/[^/]+(?=\/|$)/, "");
}
/** 完整管理端 path(主站形态,如 `/sch/demo/article`)取其学校壳内子路径(`/article`);非壳内路径返回原值 */
export function schoolSubPath(fullPath: string): string {
const p = stripSiteSeg(String(fullPath ?? ""));
const m = p.match(/^\/sch\/[^/]+(\/.*)?$/);
return m ? m[1] || "" : p;
}
export interface RouteSiteHit {
/** 命中的站点(无段=主站;非法段也回主站,配合 invalid 打回 URL) */
site: { id: number; code: string; name: string; path?: string; parentSiteId?: number } | null;
/** URL 站点段非法/跨校:调用方应打回同页主站形态 */
invalid: boolean;
}
/**
* URL 站点段 → 目标站点(docs/13 §3 守卫的纯函数核心,便于单测):
* - 无 siteCode(主站形态 `/sch/{code}/**`)→ 主站;
* - 有 siteCode → 在本校站点中按 code 匹配 **且** parentSiteId 归属本校主站(权限复用 user.sites);
* 未命中(不存在/他校/把主站编码当子站段)→ invalid,site 回主站供打回使用。
*/
export function resolveRouteSite(
sites: { id: number; code: string; name: string; path?: string; parentSiteId?: number }[],
mainSiteId: number,
siteCode?: string | null
): RouteSiteHit {
const main = sites.find(s => s.id === mainSiteId) || null;
const code = String(siteCode ?? "").trim();
if (!code) {
return { site: main, invalid: false };
}
const hit = sites.find(s => s.code === code && s.parentSiteId === mainSiteId) || null;
return hit ? { site: hit, invalid: false } : { site: main, invalid: true };
}
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
/**
* 管理端主题档位状态回归(yh-theme 迁移方案 §6.5):
* 持久化读写、脏数据回退默认档、applyThemeState 类名幂等互斥。
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_THEME_STATE,
THEME_STORAGE_KEY,
applyThemeState,
initTheme,
loadThemeState,
parseThemeState,
saveThemeState,
} from "@/utils/theme";
function memStore(init: Record<string, string> = {}) {
const map = new Map(Object.entries(init));
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
_dump: () => Object.fromEntries(map),
};
}
describe("parseThemeState", () => {
it("null/坏 JSON/非对象一律回退默认档", () => {
expect(parseThemeState(null)).toEqual(DEFAULT_THEME_STATE);
expect(parseThemeState("{oops")).toEqual(DEFAULT_THEME_STATE);
expect(parseThemeState("[1,2]")).toEqual(DEFAULT_THEME_STATE);
});
it("单字段非法只回退该字段,其余保留", () => {
const s = parseThemeState(JSON.stringify({ theme: "neon", gradOn: false }));
expect(s.theme).toBe("blue");
expect(s.gradOn).toBe(false);
expect(s.sideDeep).toBe(true);
});
it("全合法字段按存档恢复", () => {
const raw = JSON.stringify({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: false });
expect(parseThemeState(raw)).toEqual({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: false });
});
});
describe("load/saveThemeState", () => {
it("save 后 load 往返一致,键名约定 yh-theme", () => {
const store = memStore();
const state = { theme: "ocean" as const, sideDeep: true, gradOn: false, sideFollow: true };
saveThemeState(state, store);
expect(Object.keys(store._dump())).toEqual([THEME_STORAGE_KEY]);
expect(loadThemeState(store)).toEqual(state);
});
it("storage 缺失/抛错不崩溃(隐私模式兜底)", () => {
expect(loadThemeState(undefined)).toEqual(DEFAULT_THEME_STATE);
const broken = { getItem: () => null, setItem: vi.fn(() => { throw new Error("quota"); }) };
expect(() => saveThemeState(DEFAULT_THEME_STATE, broken)).not.toThrow();
});
});
describe("applyThemeState", () => {
const root = () => document.documentElement;
beforeEach(() => {
root().className = "";
delete root().dataset.theme;
});
it("默认档挂 data-theme=blue + side-deep + grad-on,且不出现互斥类", () => {
applyThemeState(DEFAULT_THEME_STATE, root());
expect(root().dataset.theme).toBe("blue");
expect(root().classList).toContain("side-deep");
expect(root().classList).not.toContain("side-light");
expect(root().classList).toContain("grad-on");
expect(root().classList).not.toContain("grad-off");
expect(root().classList).not.toContain("side-plain");
});
it("反复切换幂等:任意顺序调用后类名集合恰好匹配最后状态", () => {
applyThemeState({ theme: "violet", sideDeep: false, gradOn: true, sideFollow: false }, root());
applyThemeState({ theme: "ink", sideDeep: true, gradOn: false, sideFollow: true }, root());
expect(root().dataset.theme).toBe("ink");
expect([...root().classList].sort()).toEqual(["grad-off", "side-deep"]);
});
it("initTheme 读 localStorage 恢复现场(启动引导路径)", () => {
window.localStorage.setItem(
THEME_STORAGE_KEY,
JSON.stringify({ theme: "teal", sideDeep: false, gradOn: true, sideFollow: true })
);
const st = initTheme();
expect(st.theme).toBe("teal");
expect(root().dataset.theme).toBe("teal");
expect([...root().classList].sort()).toEqual(["grad-on", "side-light"]);
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* 管理端主题状态(yh-theme,定稿 07-seam-pure.html,方案《管理端整体主题迁移方案》§6.5)。
* 档位全部挂在 <html>:data-theme × side-deep|side-light × grad-on|grad-off × side-plain,
* 皮肤规则在 assets/styles/yh-theme.css(生成物)。与门户皮肤包(templates/themes)正交。
*/
export const THEME_NAMES = ["blue", "ocean", "ink", "violet", "teal"] as const;
export type ThemeName = (typeof THEME_NAMES)[number];
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
export interface ThemeState {
theme: ThemeName;
/** 侧栏深色(false=浅色档) */
sideDeep: boolean;
/** 顶栏渐变(联动侧栏竖向渐变) */
gradOn: boolean;
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
sideFollow: boolean;
}
export const THEME_STORAGE_KEY = "yh-theme";
export const DEFAULT_THEME_STATE: ThemeState = {
theme: "blue",
sideDeep: true,
gradOn: true,
sideFollow: true,
};
/** 宽松解析:任一字段非法/缺失即回退默认档(脏数据不阻塞启动) */
export function parseThemeState(raw: string | null): ThemeState {
if (!raw) return { ...DEFAULT_THEME_STATE };
try {
const o = JSON.parse(raw);
if (!o || typeof o !== "object") return { ...DEFAULT_THEME_STATE };
return {
theme: THEME_NAMES.includes(o.theme) ? o.theme : DEFAULT_THEME_STATE.theme,
sideDeep: typeof o.sideDeep === "boolean" ? o.sideDeep : DEFAULT_THEME_STATE.sideDeep,
gradOn: typeof o.gradOn === "boolean" ? o.gradOn : DEFAULT_THEME_STATE.gradOn,
sideFollow: typeof o.sideFollow === "boolean" ? o.sideFollow : DEFAULT_THEME_STATE.sideFollow,
};
} catch {
return { ...DEFAULT_THEME_STATE };
}
}
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
}
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
if (!storage) return { ...DEFAULT_THEME_STATE };
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
}
export function saveThemeState(
state: ThemeState,
storage: StorageLike | undefined = globalThis.localStorage
): void {
try {
storage?.setItem(THEME_STORAGE_KEY, JSON.stringify(state));
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 把状态落到根元素档位类名(幂等:先清后挂),返回传入的 state 便于链式 */
export function applyThemeState(
state: ThemeState,
root: HTMLElement = document.documentElement
): ThemeState {
root.dataset.theme = state.theme;
root.classList.toggle("side-deep", state.sideDeep);
root.classList.toggle("side-light", !state.sideDeep);
root.classList.toggle("grad-on", state.gradOn);
root.classList.toggle("grad-off", !state.gradOn);
root.classList.toggle("side-plain", !state.sideFollow);
return state;
}
/** 启动引导:main.ts 于挂载前调用,消除首帧闪色 */
export function initTheme(): ThemeState {
return applyThemeState(loadThemeState());
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 站点编辑弹窗的主题归一(任务三,2026-09-08):
* 旧行为把「不在皮肤包列表中的主题」静默归一为 default,一保存就悄悄改写站点的换肤结果
* (皮肤包列表加载失败、或站点现用外部包未注册时必现,表现为"改了没变/被改回默认")。
* 新行为:保留原值交服务端注册表校验(保存未注册主题会被明确拒绝),仅提示告警。
*/
export interface EditableTheme {
/** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default;下架别名解析为保留主题) */
theme: string;
/** 主题未在注册列表中(保存将被服务端拒绝,需提示用户改选或先上传) */
unregistered: boolean;
/** docs/20 E:下架主题别名解析到的 colorScheme 预设(保存后保持原配色;非别名为 undefined) */
aliasColorScheme?: string;
}
/** docs/20 E:下架主题别名表(与后端 ThemeRegistry.DEPRECATED_ALIASES 同构,两侧同步维护) */
const DEPRECATED_ALIASES: Record<string, { target: string; colorScheme: string }> = {
"blue-white": { target: "default", colorScheme: "blue-white" },
"classic-red": { target: "default", colorScheme: "classic-red" },
"fresh-mint": { target: "default", colorScheme: "fresh-mint" },
"warm-kinder": { target: "default", colorScheme: "warm-kinder" },
"academic-navy": { target: "scholar-blue", colorScheme: "navy" },
"campus-azure": { target: "scholar-blue", colorScheme: "azure" },
"poly-blue": { target: "scholar-blue", colorScheme: "poly" },
"agri-forest": { target: "scholar-blue", colorScheme: "agri" },
"jade-normal": { target: "scholar-blue", colorScheme: "jade" },
"mint-breeze": { target: "scholar-blue", colorScheme: "mint" },
};
export function resolveEditableTheme(rowTheme: string | undefined, themeNames: string[]): EditableTheme {
const value = rowTheme || "default";
if (!themeNames.length) {
// 列表加载失败/为空:无法判定注册状态,保留原值且不误报(由服务端校验兜底)
return { theme: value, unregistered: false };
}
// docs/20 E:下架主题 → 呈现归一后的保留主题(与服务端保存归一化一致,不误报"未注册")
const alias = DEPRECATED_ALIASES[value];
if (alias && themeNames.includes(alias.target)) {
return { theme: alias.target, unregistered: false, aliasColorScheme: alias.colorScheme };
}
return { theme: value, unregistered: !themeNames.includes(value) };
}
+61 -59
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import * as themeParamForm from "./themeParamForm";
import { import {
COLOR_RE, COLOR_RE,
formModelFromTheme, effectiveValue,
invalidParamKeys, invalidParamKeys,
isValidParamValue, isValidParamValue,
mergeThemeParamsIntoConfigJson, paramValuesOf,
themeParamsFromConfigJson,
} from "./themeParamForm"; } from "./themeParamForm";
import { ThemeVo } from "@/types/cms"; import { ThemeVo } from "@/types/cms";
@@ -30,26 +30,69 @@ function theme(): ThemeVo {
}; };
} }
describe("themeParamForm(方案 §6.3/§8.4)", () => { describe("themeParamForm(方案 §6.3/§8.4 + 工作项 T 包级参数)", () => {
it("T28: schema→表单模型:四类型各映射为对应生效值 + 默认值兜底", () => { it("T-T-08: paramValuesOf 以包 schema + paramValues 组装,缺项回落 defaultValue", () => {
const model = formModelFromTheme(theme()); const t = theme();
expect(model.primary).toBe("#b01a2e"); t.paramValues = { primary: "#123456" };
expect(model.bg).toBe("#f7f5f2"); const model = paramValuesOf(t);
expect(model.primary).toBe("#123456"); // 包级微调值
expect(model.bg).toBe("#f7f5f2"); // 未微调 → theme.json 默认值
expect(model.headerStyle).toBe("banner"); expect(model.headerStyle).toBe("banner");
expect(model.logoImage).toBe(""); expect(model.logoImage).toBe("");
}); });
it("T28: 已有合法值保留,非法显式值回退默认", () => { it("T-T-08: 覆盖层里的非法值回退默认、未声明 key 不入模型(与后端 filterValidValues 同语义)", () => {
const model = formModelFromTheme(theme(), { const t = theme();
primary: "#123456", // 合法,保留 t.paramValues = { primary: "red;}", bg: "#000000", zzhack: "zzvalue" };
bg: "red;}", // 非法,回退默认 const model = paramValuesOf(t);
headerStyle: "plain", // select 枚举内合法 expect(model.primary).toBe("#b01a2e"); // 非法显式值 → 默认
logoImage: "javascript:alert(1)", // 非法 image expect(model.bg).toBe("#000000");
expect(model.zzhack).toBeUndefined();
}); });
expect(model.primary).toBe("#123456");
expect(model.bg).toBe("#f7f5f2"); it("T-T-08: 无 paramValues(新包/未微调)全量回落默认", () => {
expect(model.headerStyle).toBe("plain"); expect(paramValuesOf(theme())).toEqual({
expect(model.logoImage).toBe(""); primary: "#b01a2e",
bg: "#f7f5f2",
headerStyle: "banner",
logoImage: "",
});
});
it("R5-1 根治:paramValuesOf 只有「一个包」一个入参(无跨包 existing 通道),换包即取新包自身值", () => {
expect(paramValuesOf.length).toBe(1);
const nextPack = theme();
nextPack.name = "another-pack";
nextPack.params = nextPack.params.map(p => ({ ...p, defaultValue: p.type === "color" ? "#0a5c36" : p.defaultValue }));
// 上一个包微调成 #123456 后切到未微调的新包:只能是新包默认值,旧包值无处带入
expect(paramValuesOf(nextPack).primary).toBe("#0a5c36");
expect(paramValuesOf(nextPack).primary).not.toBe("#123456");
});
it("T-T-07: 站点级装拆随参数迁包退役(formModelFromTheme / merge…IntoConfigJson / …FromConfigJson 不再导出)", () => {
const mod = themeParamForm as unknown as Record<string, unknown>;
expect(mod.formModelFromTheme).toBeUndefined();
expect(mod.mergeThemeParamsIntoConfigJson).toBeUndefined();
expect(mod.themeParamsFromConfigJson).toBeUndefined();
// 模块出口收敛为「纯 schema + 值校验」:不再感知 configJson
expect(Object.keys(mod).sort()).toEqual(
[
"COLOR_RE",
"KEY_RE",
"effectiveValue",
"hasParamType",
"invalidParamKeys",
"isValidParamValue",
"paramValuesOf",
].sort()
);
});
it("T28: 生效值兜底 —— 合法显式值优先,非法/缺失回退默认", () => {
const primary = theme().params.find(p => p.key === "primary")!;
expect(effectiveValue(primary, { primary: "#123456" })).toBe("#123456");
expect(effectiveValue(primary, { primary: "red;}" })).toBe("#b01a2e");
expect(effectiveValue(primary, {})).toBe("#b01a2e");
}); });
it("T29: 值校验 —— color 正则通过/拒绝", () => { it("T29: 值校验 —— color 正则通过/拒绝", () => {
@@ -88,45 +131,4 @@ describe("themeParamForm(方案 §6.3/§8.4)", () => {
expect(bad).toContain("headerStyle"); expect(bad).toContain("headerStyle");
expect(bad).not.toContain("primary"); expect(bad).not.toContain("primary");
}); });
it("T30: themeParams↔configJson 装拆 —— 合并保留其余键 + 丢弃未声明键", () => {
const t = theme();
const merged = mergeThemeParamsIntoConfigJson(
'{"logo":"/upload/cms/logo.png","contact":"0571-88888888"}',
t,
{ primary: "#ff0000", headerStyle: "plain", zzhack: "zzvalue" },
);
const parsed = JSON.parse(merged);
expect(parsed.logo).toBe("/upload/cms/logo.png");
expect(parsed.contact).toBe("0571-88888888");
expect(parsed.theme_params.primary).toBe("#ff0000");
expect(parsed.theme_params.headerStyle).toBe("plain");
expect(parsed.theme_params).not.toHaveProperty("zzhack"); // 未声明 key 丢弃(后端同语义)
});
it("T30: 空/非法 configJson 兼容;无有效参数不写入 theme_params 键", () => {
const t = theme();
const merged = mergeThemeParamsIntoConfigJson("{bad json", t, {});
expect(JSON.parse(merged)).toEqual({});
const merged2 = mergeThemeParamsIntoConfigJson(
'{"theme_params":{"primary":"#b01a2e"},"logo":"x"}',
t,
{ headerStyle: "banner" },
);
const parsed2 = JSON.parse(merged2);
expect(parsed2.logo).toBe("x");
expect(parsed2.theme_params).toEqual({ headerStyle: "banner" }); // 旧的非法/未更新值被替换
});
it("T30: configJson → themeParams 编辑回显(仅声明 key + 非法值丢弃)", () => {
const t = theme();
const model = themeParamsFromConfigJson(
'{"theme_params":{"primary":"#b01a2e","zzhack":"x","headerStyle":"plain"}}',
t,
);
expect(model.primary).toBe("#b01a2e");
expect(model.headerStyle).toBe("plain");
expect(model.zzhack).toBeUndefined();
});
}); });
+16 -62
View File
@@ -1,10 +1,11 @@
import { ThemeParamVo, ThemeVo } from "@/types/cms"; import { ThemeParamVo, ThemeVo } from "@/types/cms";
/** /**
* themeParams 纯函数模块(方案 §6.3,评审 R10/R6):schema → 表单模型映射、 * 主题参数纯函数模块(方案 §6.3 + 工作项 T):包级 `paramValues` → 表单模型、值校验
* 值校验(color 正则 / select 枚举 / image 仅 /upload/**)、themeParams ↔ configJson 装拆。 * (color 正则 / select 枚举 / image 仅 /cms|/yschcms + /upload/** / text 长度)。
* 与后端合并语义(方案 §5.3)保持一致:仅替换 theme_params 键、保留其余键、丢弃未声明键。 * 工作项 T 起参数值归属皮肤包(`themes/.meta/{name}.json`),站点级 `config_json.theme_params`
* vitest 单测对象(§8.4 T28-T30),不依赖组件挂载基建。 * 装拆函数(formModelFromTheme / mergeThemeParamsIntoConfigJson / themeParamsFromConfigJson)随之后端退役一并删除。
* vitest 单测对象(§8.4 T28-T30、T-T-07/08),不依赖组件挂载基建。
*/ */
/** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */ /** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(与后端 ThemeParam.COLOR_PATTERN 一致) */
@@ -36,18 +37,24 @@ export function isValidParamValue(param: ThemeParamVo, value: string | undefined
} }
} }
/** 取参数生效值:显式值合法用之;否则回退 defaultValue(缺省也视为合法默认) */ /** T28: 生效值兜底 —— 合法显式值优先,否则回退 defaultValue(缺省也视为合法默认) */
export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string { export function effectiveValue(param: ThemeParamVo, model: FormModelValue): string {
const raw = model[param.key]; const raw = model[param.key];
if (isValidParamValue(param, raw)) return raw as string; if (isValidParamValue(param, raw)) return raw as string;
return param.defaultValue ?? ""; return param.defaultValue ?? "";
} }
/** T28: schema → 表单模型(声明过的 key 全部带上生效值) */ /**
export function formModelFromTheme(theme: ThemeVo, existing: FormModelValue = {}): FormModelValue { * T-T-08: 包级参数值 → 表单模型(工作项 T,方案 v1 §三)。
* 值来源只有一个:该包自己的 `paramValues`(`.meta` 覆盖层);未微调的 key 回落 `params[].defaultValue`,
* 非法值与未声明 key 丢弃(与后端 `ThemeInfo.filterValidValues` 同语义)。
* 入参只有「一个包」——不存在传入上一个包值的通道,R5-1(换肤颜色继承)在函数签名层面即不成立。
*/
export function paramValuesOf(theme: ThemeVo): FormModelValue {
const stored = theme.paramValues || {};
const model: FormModelValue = {}; const model: FormModelValue = {};
theme.params.forEach(p => { (theme.params || []).forEach(p => {
const raw = existing[p.key]; const raw = stored[p.key];
model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? ""); model[p.key] = isValidParamValue(p, raw) ? (raw as string) : (p.defaultValue ?? "");
}); });
return model; return model;
@@ -65,59 +72,6 @@ export function invalidParamKeys(theme: ThemeVo, model: FormModelValue): string[
return result; return result;
} }
/** T30a: 服务端合并语义(与后端 §5.3 一致)—— 仅替换 theme_params、保留其余键、丢弃未声明/非法值 */
export function mergeThemeParamsIntoConfigJson(configJson: string | undefined | null,
theme: ThemeVo,
model: FormModelValue): string {
let config: Record<string, unknown> = {};
if (configJson && configJson.trim()) {
try {
const parsed = JSON.parse(configJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
config = { ...parsed };
}
} catch {
// 非合法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
}
const filtered: Record<string, string> = {};
theme.params.forEach(p => {
const v = model[p.key];
if (isValidParamValue(p, v)) {
filtered[p.key] = v as string;
}
});
if (Object.keys(filtered).length === 0) {
delete config.theme_params;
} else {
config.theme_params = filtered;
}
return JSON.stringify(config);
}
/** T30b: configJson → themeParams(编辑回显:仅取声明过的 key,非法值丢弃) */
export function themeParamsFromConfigJson(configJson: string | undefined | null,
theme: ThemeVo): FormModelValue {
const model: FormModelValue = {};
if (!configJson) return model;
try {
const parsed = JSON.parse(configJson);
const tp = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).theme_params
: undefined;
if (tp && typeof tp === "object") {
theme.params.forEach(p => {
const raw = (tp as Record<string, unknown>)[p.key];
const v = typeof raw === "string" ? raw : "";
if (isValidParamValue(p, v)) model[p.key] = v;
});
}
} catch {
// 非合法 JSON 按无参数处理
}
return model;
}
/** 主题是否声明了某类型参数(组件渲染分支用) */ /** 主题是否声明了某类型参数(组件渲染分支用) */
export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean { export function hasParamType(theme: ThemeVo | undefined | null, type: string): boolean {
return !!theme?.params?.some(p => p.type === type); return !!theme?.params?.some(p => p.type === type);
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
/**
* useTableHeight 响应化回归(svc-hall 走查教训:一次性快照在窗口缩放后高度冻结)。
* 契约:返回 Ref<number>(模板绑定自动解包);document.body 尺寸变化 → 重算;
* 作用域销毁 → ResizeObserver.disconnect。常量保持 56/56 档不动(P2 才紧凑化)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { effectScope } from "vue";
import Utils from "@/utils/index";
let disconnectCount = 0;
function setBodyHeight(px: number) {
Object.defineProperty(document.body, "clientHeight", { value: px, configurable: true });
}
beforeEach(() => {
disconnectCount = 0;
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {
disconnectCount++;
}
}
);
// rAF 同步执行,便于断言
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => (cb(), 1));
vi.stubGlobal("cancelAnimationFrame", () => {});
setBodyHeight(800);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Utils.useTableHeight(响应式)", () => {
it("初始值按常量表计算:800 − 112(顶栏+面包屑) − 8 − 48(操作区) = 632", () => {
const scope = effectScope();
const h = scope.run(() => Utils.useTableHeight({ isOperat: true }))!;
expect(h.value).toBe(632);
scope.stop();
});
it("各开关叠加扣减:search52 + tabs56 + page72", () => {
const scope = effectScope();
const h = scope.run(() =>
Utils.useTableHeight({ isSearch: true, isTabs: true, isPage: true, otherH: 20 })
)!;
expect(h.value).toBe(800 - 112 - 8 - 52 - 56 - 72 - 20);
scope.stop();
});
it("body 高度变化 → ref 自动重算(不再冻结)", () => {
// 用可回写实例收集回调
let cb: (() => void) | undefined;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(c: () => void) {
cb = c;
}
observe() {}
disconnect() {
disconnectCount++;
}
}
);
const scope = effectScope();
const h = scope.run(() => Utils.useTableHeight({ isOperat: true }))!;
expect(h.value).toBe(632);
setBodyHeight(1000);
cb!();
expect(h.value).toBe(1000 - 112 - 8 - 48);
scope.stop();
});
it("作用域销毁 → disconnect 释放观察器", () => {
const scope = effectScope();
scope.run(() => Utils.useTableHeight({}));
expect(disconnectCount).toBe(0);
scope.stop();
expect(disconnectCount).toBe(1);
});
it("无 ResizeObserver 环境(老内核/独立 node)→ 优雅降级为静态值", () => {
vi.stubGlobal("ResizeObserver", undefined);
const scope = effectScope();
const h = scope.run(() => Utils.useTableHeight({ isOperat: true }))!;
expect(h.value).toBe(632);
scope.stop(); // 不抛错
});
});
+23 -70
View File
@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms"; import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
import RichTextEditor from "@/components/RichTextEditor.vue";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config"; import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import { portalUrlOf } from "@/utils/sitePath";
import { CategoryTreeNode } from "@/types/cms"; import { CategoryTreeNode } from "@/types/cms";
const route = useRoute(); const route = useRoute();
@@ -55,46 +53,11 @@ function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; l
return list; return list;
} }
/* ===== wangEditor(Notice 已趟坑配置:无视频组、自定义图片上传) ===== */ /* 富文本正文:走公共 RichTextEditor(审计批次 6 起与事项页共用一份实现,
const editorRef = shallowRef(); 消除原内联 wangEditor 的重复 customUpload;编辑器内贴图 id 由组件 emit uploaded 收集) */
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] }; function onEditorUploaded(attachmentId: number) {
const serviceBase = config.isMock ? "" : config.serviceHost; uploadedIds.value.push(attachmentId);
const editorConfig = {
placeholder: "请输入正文……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
fd.append("siteId", String(store.siteId));
if (editId.value) fd.append("articleId", String(editId.value));
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
uploadedIds.value.push(res.data.id);
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
} }
} catch (e) {
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
editorLoading.value = false;
}
onBeforeUnmount(() => {
editorRef.value?.destroy();
});
/* ===== 封面上传 ===== */ /* ===== 封面上传 ===== */
function coverUploaded(res: any) { function coverUploaded(res: any) {
@@ -176,7 +139,10 @@ function buildRequest() {
async function save(): Promise<number | undefined> { async function save(): Promise<number | undefined> {
const valid = await formRef.value?.validate(); const valid = await formRef.value?.validate();
if (!valid) return undefined; if (!valid) {
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
return undefined;
}
saving.value = true; saving.value = true;
try { try {
return await articleApi.save(buildRequest()).send(); return await articleApi.save(buildRequest()).send();
@@ -190,7 +156,8 @@ async function saveDraft() {
if (id) { if (id) {
Message.success("保存成功!"); Message.success("保存成功!");
const code = store.schoolCode; const code = store.schoolCode;
router.replace(code ? `/sch/${code}/article/edit/${id}` : "/workbench"); // 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
router.replace(code ? adminPathOf(code, store.siteSegCode, `article/edit/${id}`) : "/workbench");
} }
} }
@@ -200,7 +167,7 @@ async function saveAndPublish() {
await articleApi.publish(store.siteId!, id).send(); await articleApi.publish(store.siteId!, id).send();
Message.success("已提交发布!"); Message.success("已提交发布!");
const code = store.schoolCode; const code = store.schoolCode;
router.replace(code ? `/sch/${code}/article` : "/workbench"); router.replace(code ? adminPathOf(code, store.siteSegCode, "article") : "/workbench");
} }
/** 预览:仅已发布文章在门户可见 */ /** 预览:仅已发布文章在门户可见 */
@@ -281,16 +248,15 @@ watch(() => store.siteId, () => load());
<Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" /> <Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" />
</FormItem> </FormItem>
<FormItem label="正文"> <FormItem label="正文">
<div class="editor-wrap"> <RichTextEditor
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
v-model="form.content" v-model="form.content"
:defaultConfig="editorConfig" :site-id="store.siteId"
mode="default" :article-id="editId"
@onCreated="handleCreated" placeholder="请输入正文……"
slate-min-height="max(480px, calc(100vh - 430px))"
class="editor-full"
@uploaded="onEditorUploaded"
/> />
</div>
</FormItem> </FormItem>
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 --> <!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
<FormItem label="同步公众号"> <FormItem label="同步公众号">
@@ -322,20 +288,7 @@ watch(() => store.siteId, () => load());
color: #999; color: #999;
font-size: 12px; font-size: 12px;
} }
.editor-wrap { .editor-full {
border: 1px solid #dcdee2; width: 100%;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
// wangEditor v5 高度必须打在内层 .w-e-text-container(外层高度会被内部 100% 链路吞掉),
// Notice NewNotice.vue 同款修复(D6-5);视口自适应、下限 480px,430≈壳层(56+56)+表单区高度
:deep(.w-e-text-container) {
height: max(480px, calc(100vh - 430px)) !important;
}
overflow-y: hidden;
}
} }
</style> </style>
+14 -6
View File
@@ -116,6 +116,13 @@ function uploadDone(res: any) {
} }
} }
function pageSizeUpdate(size: number) {
pageSize.value = size;
localStorage.setItem("customPageCount", String(size));
pageNum.value = 1;
run();
}
onMounted(run); onMounted(run);
// 站点上下文切换即时重载(docs/09 §3.3) // 站点上下文切换即时重载(docs/09 §3.3)
@@ -128,7 +135,7 @@ watch(() => store.siteId, () => {
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container"> <div class="main-container">
<div class="fix"> <div class="fix mb-12">
<Upload <Upload
class="fl" class="fl"
:action="uploadUrl() + '?siteId=' + store.siteId" :action="uploadUrl() + '?siteId=' + store.siteId"
@@ -140,14 +147,15 @@ watch(() => store.siteId, () => {
> >
<Button type="primary" size="large" icon="ios-cloud-upload-outline">上传附件</Button> <Button type="primary" size="large" icon="ios-cloud-upload-outline">上传附件</Button>
</Upload> </Upload>
<Button class="fl" style="margin-left: 8px" size="large" @click="cleanOrphans"> <Button class="fr" size="large" @click="cleanOrphans">
未使用附件({{ unusedCount }}) 未使用附件({{ unusedCount }})
</Button> </Button>
</div> </div>
<div class="search-wrap"> <div class="search-wrap fix">
<Checkbox v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看未使用附件</Checkbox> <Checkbox class="fl" v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看未使用附件</Checkbox>
</div> </div>
<Table <Table
class="link-table table-bottom-box"
:columns="columns" :columns="columns"
:data="rows" :data="rows"
:height="tableHeight" :height="tableHeight"
@@ -156,12 +164,12 @@ watch(() => store.siteId, () => {
/> />
<div class="page-panel"> <div class="page-panel">
<Page <Page
class="fr" class="page-box fr"
:total="total" :total="total"
:current="pageNum" :current="pageNum"
:page-size="pageSize" :page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]" :page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="(s: number) => { pageSize = s; pageNum = 1; run(); }" @on-page-size-change="pageSizeUpdate"
@on-change="(p: number) => { pageNum = p; run(); }" @on-change="(p: number) => { pageNum = p; run(); }"
placement="top" placement="top"
show-total show-total
+93 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import Utils from "@/utils/index"; import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
@@ -20,6 +20,20 @@ const linkTypeDict = [
]; ];
const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-"; const linkTypeText = (v: number) => linkTypeDict.find(i => i.value === v)?.label || "-";
/** 展示位(docs/12;docs/20 F4 双 Tab 化):home=首页轮播 / gallery=线上展厅图片画廊 */
const position = ref("home");
/** 弹窗打开时的展示位锚点(F4:保存归属以打开时所在 Tab 为准,弹窗期间切换 Tab 不串数据) */
const formPosition = ref("home");
const positionDict = [
{ value: "home", label: "首页轮播", short: "轮播", addLabel: "新增轮播", tip: "轮播图设计尺寸建议 1920×560(皮肤包约定)" },
{ value: "gallery", label: "线上展厅", short: "展厅图片", addLabel: "新增展厅图片", tip: "画廊图片设计尺寸建议 800×520,多图横向轮播(炫彩校园皮肤消费)" },
// docs/20 D-3/D-6:新展示位(首页布局 featured_topics / events 区块消费)
{ value: "topic", label: "专题大卡", short: "专题卡", addLabel: "新增专题卡", tip: "专题大卡图片建议 800×500;首页需添加「专题大卡」区块才会展示" },
{ value: "event", label: "学术活动", short: "活动", addLabel: "新增活动", tip: "学术报告/活动海报位;首页需添加「学术日历」区块才会展示,按活动日期升序排列" },
];
const formPositionMeta = computed(() => positionDict.find(p => p.value === formPosition.value)!);
const modalTitle = computed(() => `${form.id ? "编辑" : "新增"}${formPositionMeta.value.short}`);
const columns = [ const columns = [
{ {
title: "图片", title: "图片",
@@ -83,6 +97,12 @@ const form = reactive({
startTime: "" as string | "", startTime: "" as string | "",
endTime: "" as string | "", endTime: "" as string | "",
status: 1, status: 1,
// docs/20 D-3/D-6:topic/event 展示位扩展字段
description: "",
ctaText: "",
ctaUrl: "",
eventDate: "" as string | "",
location: "",
}); });
const rules = { const rules = {
@@ -94,7 +114,7 @@ async function run() {
if (!store.siteId) return; if (!store.siteId) return;
loading.value = true; loading.value = true;
try { try {
rows.value = (await bannerApi.list(store.siteId, "home").send()) || []; rows.value = (await bannerApi.list(store.siteId, position.value).send()) || [];
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -111,7 +131,13 @@ function openCreate() {
startTime: "", startTime: "",
endTime: "", endTime: "",
status: 1, status: 1,
description: "",
ctaText: "",
ctaUrl: "",
eventDate: "",
location: "",
}); });
formPosition.value = position.value;
modalVisible.value = true; modalVisible.value = true;
} }
@@ -126,7 +152,13 @@ function openEdit(row: BannerVo) {
startTime: row.startTime || "", startTime: row.startTime || "",
endTime: row.endTime || "", endTime: row.endTime || "",
status: row.status, status: row.status,
description: row.description || "",
ctaText: row.ctaText || "",
ctaUrl: row.ctaUrl || "",
eventDate: row.eventDate || "",
location: row.location || "",
}); });
formPosition.value = position.value;
modalVisible.value = true; modalVisible.value = true;
} }
@@ -140,9 +172,13 @@ function bannerUploaded(res: any) {
async function save() { async function save() {
const valid = await formRef.value?.validate(); const valid = await formRef.value?.validate();
if (!valid) return; if (!valid) {
// bsch 规范 §4:校验失败统一提示,不逐字段弹
Message.warning("请填写红色星号内容!");
return;
}
if (!form.imageUrl) { if (!form.imageUrl) {
Message.warning("请上传轮播图片"); Message.warning("请上传图片");
return; return;
} }
saving.value = true; saving.value = true;
@@ -151,7 +187,8 @@ async function save() {
.save({ .save({
id: form.id, id: form.id,
siteId: store.siteId!, siteId: store.siteId!,
position: "home", // F4:保存归属以弹窗打开时所在 Tab 为准(弹窗期间切换 Tab 不串数据)
position: formPosition.value,
title: form.title, title: form.title,
imageUrl: form.imageUrl, imageUrl: form.imageUrl,
linkType: form.linkType, linkType: form.linkType,
@@ -160,6 +197,12 @@ async function save() {
startTime: form.startTime || undefined, startTime: form.startTime || undefined,
endTime: form.endTime || undefined, endTime: form.endTime || undefined,
status: form.status, status: form.status,
// docs/20 D-3/D-6:topic/event 位扩展字段随行提交(其余位后端落库但不消费)
description: form.description || undefined,
ctaText: form.ctaText || undefined,
ctaUrl: form.ctaUrl || undefined,
eventDate: form.eventDate || undefined,
location: form.location || undefined,
}) })
.send(); .send();
Message.success("保存成功!"); Message.success("保存成功!");
@@ -172,7 +215,7 @@ async function save() {
function doDelete(row: BannerVo) { function doDelete(row: BannerVo) {
MyConfirm({ MyConfirm({
title: "删除轮播", title: `删除${formPositionMeta.value.short}`,
msg: `确定删除「${row.title}」吗?`, msg: `确定删除「${row.title}」吗?`,
async onOk() { async onOk() {
await bannerApi.delete({ id: row.id!, siteId: store.siteId! }).send(); await bannerApi.delete({ id: row.id!, siteId: store.siteId! }).send();
@@ -184,28 +227,42 @@ function doDelete(row: BannerVo) {
onMounted(run); onMounted(run);
// 站点上下文切换即时重载(docs/09 §3.3) // 站点上下文切换即时重载(docs/09 §3.3);展示位切换即时重载(docs/12)
watch(() => store.siteId, () => run()); watch(() => store.siteId, () => run());
watch(position, () => run());
</script> </script>
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container"> <div class="main-container">
<div class="fix"> <!-- F4(docs/20):首页轮播 / 线上展厅双 Tab——两个展示位尺寸与管理习惯不同,
各自独立列表、独立新增按钮、独立尺寸提示;切换 Tab 列表即时重载(watch position) -->
<Tabs v-model="position" class="banner-tabs">
<TabPane v-for="p in positionDict" :key="p.value" :label="p.label" :name="p.value">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="openCreate"> <Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" /> <Icon type="ios-add" />
新增轮播 {{ p.addLabel }}
</Button> </Button>
<span class="size-tip fr">轮播图设计尺寸建议 1920×560(皮肤包约定)</span> <span class="size-tip fr">{{ p.tip }}</span>
</div> </div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无轮播" /> </TabPane>
</Tabs>
<Table
class="link-table table-bottom-box"
:columns="columns"
:data="rows"
:height="tableHeight"
:loading="loading"
:no-data-text="position === 'home' ? '暂无轮播' : '暂无展厅图片'"
/>
<Modal v-model="modalVisible" :title="form.id ? '编辑轮播' : '新增轮播'" :mask-closable="false" width="560px"> <Modal v-model="modalVisible" :title="modalTitle" :mask-closable="false" width="560px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100"> <Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="标题" prop="title"> <FormItem label="标题" prop="title">
<Input v-model="form.title" :maxlength="200" placeholder="alt 文案/无图兜底" /> <Input v-model="form.title" :maxlength="200" placeholder="alt 文案/无图兜底" />
</FormItem> </FormItem>
<FormItem label="轮播图片" required> <FormItem :label="formPositionMeta.value.short" required>
<Upload <Upload
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=banner'" :action="uploadUrl() + '?siteId=' + store.siteId + '&scene=banner'"
with-credentials with-credentials
@@ -238,6 +295,29 @@ watch(() => store.siteId, () => run());
> >
<Input v-model="form.linkValue" :maxlength="64" :placeholder="form.linkType === 1 ? '文章 ID' : '栏目 ID'" /> <Input v-model="form.linkValue" :maxlength="64" :placeholder="form.linkType === 1 ? '文章 ID' : '栏目 ID'" />
</FormItem> </FormItem>
<!-- docs/20 D-3:专题大卡扩展字段(仅 topic 位) -->
<template v-if="formPosition === 'topic'">
<FormItem label="专题描述">
<Input v-model="form.description" type="textarea" :rows="2" :maxlength="500"
placeholder="卡片上一句话描述(可选)" />
</FormItem>
<FormItem label="按钮文字">
<Input v-model="form.ctaText" :maxlength="20" placeholder="如「进入专题」(可选,不填不显示按钮)" />
</FormItem>
<FormItem label="按钮地址">
<Input v-model="form.ctaUrl" :maxlength="500" placeholder="https://… 或站内 / 路径(可选)" />
</FormItem>
</template>
<!-- docs/20 D-6:学术活动扩展字段(仅 event 位) -->
<template v-if="formPosition === 'event'">
<FormItem label="活动日期">
<DatePicker type="date" v-model="form.eventDate" format="yyyy-MM-dd"
placeholder="大日期块展示(可选)" style="width: 220px" />
</FormItem>
<FormItem label="活动地点">
<Input v-model="form.location" :maxlength="200" placeholder="如 图书馆报告厅(可选)" />
</FormItem>
</template>
<FormItem label="排序"> <FormItem label="排序">
<InputNumber v-model="form.sortNo" :min="0" /> <InputNumber v-model="form.sortNo" :min="0" />
</FormItem> </FormItem>
+289 -50
View File
@@ -1,19 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, ref, useTemplateRef, watch } from "vue"; import { computed, nextTick, reactive, ref, shallowRef, useTemplateRef, watch } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import CategoryTreePanel from "@/components/CategoryTreePanel.vue"; import CategoryTreePanel from "@/components/CategoryTreePanel.vue";
import CategorySortModal from "@/components/CategorySortModal.vue"; import CategorySortModal from "@/components/CategorySortModal.vue";
import BatchAddCategory from "@/components/BatchAddCategory.vue";
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import ArticleListPanel from "@/components/article/ArticleListPanel.vue"; import CategoryContentSummary from "@/components/category/CategoryContentSummary.vue";
import { categoryApi } from "@/api/module/cms"; import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import { CategoryTreeNode } from "@/types/cms"; import { CategoryTreeNode } from "@/types/cms";
import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree"; import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree";
import { portalUrlOf } from "@/utils/sitePath"; import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */ /* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。
B5 栏目工作台:右侧类型化面板(单页=富文本预览 / 外链=直达)。
P-D1 内容管理 IA 切分:列表型栏目右侧降级为「栏目内容概览」(只读统计 + 最近 5 条),
文章的检索与操作统一在 /article(内容视图)。 */
const store = useCmsStore(); const store = useCmsStore();
const refTreePanel = useTemplateRef("refTreePanel"); const refTreePanel = useTemplateRef("refTreePanel");
@@ -29,12 +35,19 @@ const typeDict = [
{ value: 2, label: "单页" }, { value: 2, label: "单页" },
{ value: 3, label: "外链" }, { value: 3, label: "外链" },
]; ];
const typeLabel = (t?: number) => typeDict.find(i => i.value === t)?.label || "未知";
// F3(docs/20 §四):「首页展示形态」配置入口已收敛到「站点设置 → 首页布局」区块属性,
// 栏目管理只管数据(名称/类型/排序/状态/SEO);存量 homeStyle 值由后端保存时保留。
const sortModal = ref(false); const sortModal = ref(false);
const sortParentId = ref<number | null>(null); const sortParentId = ref<number | null>(null);
const sortParentName = ref(""); const sortParentName = ref("");
const sortChildrenSource = ref<any[]>([]); const sortChildrenSource = ref<any[]>([]);
const batchModal = ref(false);
const batchDefaultType = ref<number | null>(null);
const modalVisible = ref(false); const modalVisible = ref(false);
const saving = ref(false); const saving = ref(false);
@@ -55,6 +68,7 @@ const form = reactive({
const rules = { const rules = {
name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }], name: [{ required: true, message: "栏目名称不得为空", trigger: "blur" }],
type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }], type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }],
linkUrl: [{ required: true, message: "外链地址不得为空", trigger: "blur" }],
}; };
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */ /** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
@@ -95,6 +109,35 @@ function findNode(list: any[], id: number): any {
return null; return null;
} }
/** target 是否 node 自身或其子孙(成环判定:把栏目挂到自己子树下会形成环) */
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
if (!node || targetId == null) return false;
if (node.id === targetId) return true;
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
}
/**
* 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进。
*
* 2026-09-11 口径调整:**不再置灰任何选项**(含外链型栏目、含自身及其子栏目)——
* 类型与"能否作父"无关,而且灰色项只显示一个无解释的锁,用户看到的往往是
* "办事大厅被禁用了"这类误解。成环改为保存前校验(见 save())并给出明确原因,
* 后端 CategoryService.validateParent 同步升级为沿祖先链防任意层级成环。
*/
const parentOptions = computed(() => {
const list: { id: number; label: string }[] = [
{ id: 0, label: rootLabel.value || "根栏目" },
];
const walk = (nodes: any[], depth: number) => {
(nodes || []).forEach((n: any) => {
list.push({ id: n.id, label: `${" ".repeat(depth)}${n.name}` });
walk(n.children || [], depth + 1);
});
};
walk(treeData.value[0]?.children || [], 1);
return list;
});
function currentNode() { function currentNode() {
return selected.value || {}; return selected.value || {};
} }
@@ -172,9 +215,36 @@ function showDeleteNode() {
}); });
} }
/**
* 保存前成环校验:上级栏目不能是自身或自己的子栏目。
* 返回提示文案(命中)或空串(通过)。
*/
function findCycleParent(): string {
const parentId = Number(form.parentId);
if (!parentId) return "";
if (form.id != null && parentId === Number(form.id)) {
return "上级栏目不能是自身,请另选一个上级栏目";
}
const parentNode = findNode(treeData.value, parentId);
if (parentNode && isSelfOrDescendant(parentNode, form.id)) {
return `「${parentNode.name}」是当前栏目的子栏目,挂到它下面会形成循环,请另选上级栏目`;
}
return "";
}
async function save() { async function save() {
const valid = await refFormData.value?.validate(); const valid = await refFormData.value?.validate();
if (!valid) return; if (!valid) {
// bsch 规范 §4:校验失败统一提示
Message.warning("请填写红色星号内容!");
return;
}
// 成环校验(下拉已不再置灰,这里给出可读原因;后端 CategoryService.validateParent 同口径兜底)
const cycleHit = findCycleParent();
if (cycleHit) {
Message.warning(cycleHit);
return;
}
saving.value = true; saving.value = true;
try { try {
await categoryApi await categoryApi
@@ -204,17 +274,84 @@ function onSortSaved() {
refTreePanel.value?.refreshTree(curTreeNode.value?.id); refTreePanel.value?.refreshTree(curTreeNode.value?.id);
} }
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */ /** 批量添加完成:刷新树并定位到最后创建成功的节点 */
function onBatchSuccess(lastSavedId?: number) {
/** 单页摘要:去 HTML 标签截断展示 */ refTreePanel.value?.refreshTree(lastSavedId ?? curTreeNode.value?.id);
function summaryText(node: any): string {
const text = String(node?.content || "").replace(/<[^>]+>/g, "").trim();
return text.length > 200 ? text.slice(0, 200) + "…" : text || "(暂无内容,点击「编辑内容」补充)";
} }
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */ /** 新增模态框 → 批量添加(UserBase switchToBatch 同款,仅新增态开放) */
function switchToBatch() {
batchDefaultType.value = form.type;
modalVisible.value = false;
batchModal.value = true;
}
/* ===== 单页富文本编辑(wangEditor,配置对齐 ArticleEdit;贴图 scene=category 标记引用) ===== */
const editorRef = shallowRef();
const editorReady = ref(false);
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
const serviceBase = config.isMock ? "" : config.serviceHost;
const editorConfig = {
placeholder: "请输入单页内容,支持图文混排……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
fd.append("siteId", String(store.siteId));
// scene=category:单页贴图计入栏目引用,附件管理不计为「未使用」(后端白名单)
fd.append("scene", "category");
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
}
} catch {
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
}
// wangEditor 不能在 display:none 容器内创建(宽度塌陷):模态框展开动画结束后再挂载,
// 关闭即卸载销毁(@wangeditor/editor-for-vue 组件卸载时自动 destroy)
watch(modalVisible, v => {
if (v) {
editorReady.value = false;
setTimeout(() => {
editorReady.value = form.type === 2;
}, 320);
} else {
editorReady.value = false;
}
});
/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */
/** 单页正文纯文本(字数统计/空态判断用) */
function plainText(html: string): string {
return String(html || "")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
}
/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填);站点段跟随当前上下文 */
function goNewArticle(categoryId: number) { function goNewArticle(categoryId: number) {
window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`; const code = store.schoolCode;
if (!code) return;
window.location.hash = `#${adminPathOf(code, store.siteSegCode, "article/edit")}?categoryId=${categoryId}`;
} }
/** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */ /** 门户预览:新开 /portal/{path}/c/{id}(W4 嵌套路径) */
@@ -277,7 +414,7 @@ function openLink(url: string) {
<div class="panel-title"> <div class="panel-title">
<h3>{{ selected.name }}</h3> <h3>{{ selected.name }}</h3>
<Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'"> <Tag :color="selected.type === 1 ? 'primary' : selected.type === 2 ? 'success' : 'warning'">
{{ typeDict.find(t => t.value === selected!.type)?.label }} {{ typeLabel(selected.type) }}
</Tag> </Tag>
<span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span> <span class="panel-meta">排序 {{ selected.sortNo }} · {{ selected.status === 1 ? "显示" : "隐藏" }}</span>
</div> </div>
@@ -297,17 +434,30 @@ function openLink(url: string) {
</div> </div>
</div> </div>
<!-- 列表型:本栏目文章管理(联动仅经 categoryId watch,拖拽排序不触发) --> <!-- 列表型:只读内容概览(P-D1 结构/内容视图切分;文章操作统一在 /article) -->
<ArticleListPanel <CategoryContentSummary
v-if="selected.type === 1" v-if="selected.type === 1"
class="panel-body" class="panel-body"
:category-id="selected.id" :category-id="selected.id"
fixed-category :category-name="selected.name"
:tree="treeData"
/> />
<!-- 单页型:内容摘要 + 编辑入口 --> <!-- 单页型:正文渲染预览 + 元信息 + 编辑入口 -->
<div v-else-if="selected.type === 2" class="single-summary"> <div v-else-if="selected.type === 2" class="single-summary">
<p class="summary-text">{{ summaryText(selected) }}</p> <div class="summary-meta">
<span>共 {{ plainText(selected.content).length }} 字</span>
<span>{{ selected.status === 1 ? "已显示" : "已隐藏" }}</span>
</div>
<!-- content 入库前经后端 jsoup 白名单清洗,回显渲染安全 -->
<div v-if="plainText(selected.content)" class="rich-preview" v-html="selected.content"></div>
<div v-else class="summary-empty">
<p>暂无内容</p>
<Button type="primary" ghost @click="showEditNode">
<Icon type="ios-create" />
去编写
</Button>
</div>
<div class="summary-actions"> <div class="summary-actions">
<Button type="primary" @click="showEditNode"> <Button type="primary" @click="showEditNode">
<Icon type="ios-create" /> <Icon type="ios-create" />
@@ -342,11 +492,17 @@ function openLink(url: string) {
v-model="modalVisible" v-model="modalVisible"
:title="form.id ? '编辑栏目' : '新增子栏目'" :title="form.id ? '编辑栏目' : '新增子栏目'"
:mask-closable="false" :mask-closable="false"
width="560px" width="640px"
> >
<Form ref="refFormData" :model="form" :rules="rules" :label-width="100"> <Form ref="refFormData" :model="form" :rules="rules" :label-width="100">
<FormItem label="上级栏目"> <Divider size="small" orientation="left">基本信息</Divider>
<Input :value="form.parentId === 0 ? '根栏目' : '当前选中栏目下'" disabled /> <FormItem label="上级栏目" prop="parentId">
<Select v-model="form.parentId" transfer>
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</Option>
</Select>
<p class="form-tip">任何类型的栏目都可作为上级(外链型也可以);不能选自身或自己的子栏目(会成环)。</p>
</FormItem> </FormItem>
<FormItem label="栏目名称" prop="name"> <FormItem label="栏目名称" prop="name">
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" /> <Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
@@ -356,34 +512,60 @@ function openLink(url: string) {
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio> <Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
</RadioGroup> </RadioGroup>
</FormItem> </FormItem>
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
</FormItem>
<FormItem v-if="form.type === 2" label="单页内容">
<Input v-model="form.content" type="textarea" :rows="8" placeholder="单页 HTML 内容(保存时自动做安全清洗)" />
</FormItem>
<FormItem label="排序"> <FormItem label="排序">
<InputNumber v-model="form.sortNo" :min="0" /> <InputNumber v-model="form.sortNo" :min="0" />
</FormItem> </FormItem>
<FormItem label="SEO 标题">
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
</FormItem>
<FormItem label="SEO 描述">
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
</FormItem>
<FormItem label="状态"> <FormItem label="状态">
<RadioGroup v-model="form.status"> <RadioGroup v-model="form.status">
<Radio :label="1">显示</Radio> <Radio :label="1">显示</Radio>
<Radio :label="0">隐藏</Radio> <Radio :label="0">隐藏</Radio>
</RadioGroup> </RadioGroup>
</FormItem> </FormItem>
<Divider size="small" orientation="left">内容设置</Divider>
<FormItem v-if="form.type === 3" label="外链地址" prop="linkUrl">
<Input v-model="form.linkUrl" :maxlength="500" placeholder="http(s):// 开头" />
</FormItem>
<FormItem v-if="form.type === 2" label="单页内容">
<div class="editor-wrap">
<template v-if="editorReady">
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
v-model="form.content"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleCreated"
/>
</template>
<div v-else class="editor-placeholder">编辑器加载中……</div>
</div>
</FormItem>
<Divider size="small" orientation="left">SEO 设置</Divider>
<FormItem label="SEO 标题">
<Input v-model="form.seoTitle" :maxlength="200" placeholder="空=用栏目名" />
</FormItem>
<FormItem label="SEO 描述">
<Input v-model="form.seoDescription" type="textarea" :rows="2" :maxlength="500" />
</FormItem>
</Form> </Form>
<template #footer> <template #footer>
<Button v-if="!form.id" type="text" @click="switchToBatch">切换至批量添加</Button>
<Button @click="modalVisible = false">取消</Button> <Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button> <Button type="primary" :loading="saving" @click="save">保存</Button>
</template> </template>
</Modal> </Modal>
<!-- 批量添加栏目(UserBase BatchAddGroup 同款:一行一个名称,前端循环调 save) -->
<BatchAddCategory
v-model:visible="batchModal"
:default-pid="curTreeNode?.id ?? 0"
:parent-name="curTreeNode?.id ? curTreeNode?.name : rootLabel"
:default-type="batchDefaultType"
@success="onBatchSuccess"
/>
<!-- 子栏目排序(userbase GroupSort 同款拖拽) --> <!-- 子栏目排序(userbase GroupSort 同款拖拽) -->
<CategorySortModal <CategorySortModal
v-model:visible="sortModal" v-model:visible="sortModal"
@@ -396,6 +578,13 @@ function openLink(url: string) {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
/* UserBase group/index 同款工具栏样式 */ /* UserBase group/index 同款工具栏样式 */
.opera-ul { .opera-ul {
display: flex; display: flex;
@@ -441,18 +630,6 @@ function openLink(url: string) {
} }
} }
.info-panel {
background: #fff;
border-radius: 4px;
padding: 16px;
h3 {
margin-top: 0;
}
p {
color: #555;
}
}
/* ===== B5 栏目工作台右侧面板 ===== */ /* ===== B5 栏目工作台右侧面板 ===== */
.workbench-main { .workbench-main {
display: flex; display: flex;
@@ -469,6 +646,7 @@ function openLink(url: string) {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0;
.panel-title { .panel-title {
display: flex; display: flex;
@@ -510,18 +688,53 @@ function openLink(url: string) {
border-radius: 4px; border-radius: 4px;
padding: 16px; padding: 16px;
margin-top: 10px; margin-top: 10px;
flex: 1;
min-height: 0;
overflow: auto;
.summary-text { .summary-meta {
color: #333; display: flex;
gap: 16px;
color: #999;
font-size: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.rich-preview {
line-height: 1.8; line-height: 1.8;
white-space: pre-wrap;
word-break: break-all; word-break: break-all;
margin-bottom: 14px;
:deep(img) {
max-width: 100%;
}
:deep(table) {
border-collapse: collapse;
td,
th {
border: 1px solid #ccc;
padding: 4px 8px;
}
}
}
.summary-empty {
text-align: center;
color: #999;
padding: 40px 0;
p {
margin-bottom: 12px;
}
} }
.summary-actions { .summary-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-top: 14px;
} }
.seo-line { .seo-line {
@@ -545,6 +758,32 @@ function openLink(url: string) {
} }
} }
/* 单页富文本编辑器(模态框内):wangEditor v5 高度打在内层 .w-e-text-container */
.editor-wrap {
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
:deep(.w-e-text-container) {
height: 320px !important;
}
overflow-y: hidden;
}
.editor-placeholder {
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
}
.empty-tip { .empty-tip {
color: #999; color: #999;
text-align: center; text-align: center;
+13 -6
View File
@@ -127,7 +127,11 @@ function logoUploaded(res: any) {
async function save() { async function save() {
const valid = await formRef.value?.validate(); const valid = await formRef.value?.validate();
if (!valid) return; if (!valid) {
// bsch 规范 §4:校验失败统一提示
Message.warning("请填写红色星号内容!");
return;
}
saving.value = true; saving.value = true;
try { try {
await linkApi await linkApi
@@ -169,18 +173,21 @@ watch(() => store.siteId, () => run());
</script> </script>
<template> <template>
<div class="page-wrapper"> <div class="tabs-wrapper">
<!-- 分组即 tab(bsch 规范 §1:tabs-wrapper + Tabs type=card,与 svc-hall ServiceDictManage 同范式) -->
<Tabs v-if="groups.length" type="card" :value="activeGroup || '全部'" @on-click="(g: string) => (activeGroup = g === '全部' ? '' : g)">
<TabPane name="全部" label="全部" />
<TabPane v-for="g in groups" :key="g" :name="g" :label="`${g}(${rows.filter(r => r.groupName === g).length})`" />
</Tabs>
<div class="main-container"> <div class="main-container">
<div class="fix"> <div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="openCreate"> <Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" /> <Icon type="ios-add" />
新增友链 新增友链
</Button> </Button>
<Tabs v-if="groups.length" class="fr" :value="activeGroup" @on-click="(g: string) => (activeGroup = g)">
<TabPane v-for="g in groups" :key="g" :name="g" :label="`${g}(${rows.filter(r => r.groupName === g).length})`" />
</Tabs>
</div> </div>
<Table <Table
class="link-table table-bottom-box"
:columns="columns" :columns="columns"
:data="visibleRows" :data="visibleRows"
:height="tableHeight" :height="tableHeight"
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
import { categoryApi, personApi, uploadUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { CategoryTreeNode, PersonVo } from "@/types/cms";
/**
* 名师风采人物管理(docs/20 D-7,people 区块数据源)。
* 站点级 CRUD:姓名/头衔/简介/头像(附件上传 scene=person)/所属栏目(分组)/排序/状态。
* 入口:首页布局编辑器 people 区块属性面板「维护人物数据」直达;菜单挂靠见路由 meta(modId 3 轮播管理族)。
*/
const store = useCmsStore();
const rows = ref<PersonVo[]>([]);
const loading = ref(false);
const tree = ref<CategoryTreeNode[]>([]);
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true });
/** 树拍平(含虚拟根排除) */
const flat = computed(() => {
const out: { id: number; name: string; depth: number }[] = [];
const walk = (nodes: CategoryTreeNode[], depth: number) => {
(nodes || []).forEach(n => {
out.push({ id: n.id as number, name: n.name, depth });
walk(n.children || [], depth + 1);
});
};
walk(tree.value, 0);
return out;
});
const nameOfCategory = (id?: number) => (id == null ? "—" : flat.value.find(c => c.id === id)?.name || `#${id}`);
const columns = [
{
title: "头像",
key: "avatar",
width: 90,
align: "center",
render: (h: any, p: any) =>
p.row.avatar
? h("img", { src: p.row.avatar, style: { width: "48px", height: "48px", borderRadius: "50%", objectFit: "cover" } })
: h(
"span",
{ style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: "48px", height: "48px", borderRadius: "50%", background: "#e8f1ff", color: "#2d8cf0", fontWeight: 600 } },
(p.row.name || "?").slice(0, 1),
),
},
{ title: "姓名", key: "name", width: 120 },
{ title: "头衔/职称", key: "title", width: 160, tooltip: true, render: (h: any, p: any) => h("span", p.row.title || "—") },
{ title: "简介", key: "bio", minWidth: 220, tooltip: true, render: (h: any, p: any) => h("span", p.row.bio || "—") },
{
title: "所属栏目",
key: "categoryId",
width: 140,
render: (h: any, p: any) => h("span", nameOfCategory(p.row.categoryId)),
},
{ title: "排序", key: "sortNo", width: 80, align: "center" },
{
title: "状态",
key: "status",
width: 90,
align: "center",
render: (h: any, p: any) => h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
},
{
title: "操作",
key: "action",
width: 120,
fixed: "right",
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
],
},
];
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const form = reactive({
id: undefined as number | undefined,
name: "",
title: "",
bio: "",
avatar: "",
categoryId: undefined as number | undefined,
sortNo: 0,
status: 1,
});
const rules = {
name: [{ required: true, message: "姓名不得为空", trigger: "blur" }],
};
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
rows.value = (await personApi.list(store.siteId).send()) || [];
} finally {
loading.value = false;
}
}
async function loadTree() {
if (!store.siteId) return;
try {
tree.value = (await categoryApi.tree(store.siteId).send()) || [];
} catch {
tree.value = [];
}
}
function openCreate() {
Object.assign(form, {
id: undefined,
name: "",
title: "",
bio: "",
avatar: "",
categoryId: undefined,
sortNo: 0,
status: 1,
});
modalVisible.value = true;
}
function openEdit(row: PersonVo) {
Object.assign(form, {
id: row.id,
name: row.name,
title: row.title || "",
bio: row.bio || "",
avatar: row.avatar || "",
categoryId: row.categoryId,
sortNo: row.sortNo,
status: row.status,
});
modalVisible.value = true;
}
function avatarUploaded(res: any) {
if (res.code === 0 && res.data) {
form.avatar = res.data.url;
} else {
Message.error(res.msg || "头像上传失败");
}
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) {
Message.warning("请填写红色星号内容!"); // bsch §4
return;
}
saving.value = true;
try {
await personApi
.save({
id: form.id,
siteId: store.siteId!,
name: form.name,
title: form.title || undefined,
bio: form.bio || undefined,
avatar: form.avatar || undefined,
categoryId: form.categoryId || undefined,
sortNo: form.sortNo,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
}
}
function doDelete(row: PersonVo) {
MyConfirm({
title: "删除人物",
msg: `确定删除「${row.name}」吗?首页名师风采区块将不再展示该人物。`,
async onOk() {
await personApi.delete({ id: row.id!, siteId: store.siteId! }).send();
Message.success("操作成功!");
run();
},
});
}
onMounted(() => {
run();
loadTree();
});
// 站点上下文切换即时重载(docs/09 §3.3)
watch(() => store.siteId, () => {
run();
loadTree();
});
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" />
新增人物
</Button>
<span class="size-tip fr">名师风采数据源:首页布局「名师风采」区块按所属栏目过滤展示(未分组 = 全部人物区块不选栏目时可见)</span>
</div>
<Table
class="link-table table-bottom-box"
:columns="columns"
:data="rows"
:height="tableHeight"
:loading="loading"
no-data-text="暂无人物"
/>
<Modal v-model="modalVisible" :title="form.id ? '编辑人物' : '新增人物'" :mask-closable="false" width="560px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="姓名" prop="name">
<Input v-model="form.name" :maxlength="100" placeholder="如 王立群" />
</FormItem>
<FormItem label="头衔/职称">
<Input v-model="form.title" :maxlength="100" placeholder="如 教授、长江学者" />
</FormItem>
<FormItem label="简介">
<Input v-model="form.bio" type="textarea" :rows="2" :maxlength="500" placeholder="一句话简介(卡片下方展示)" />
</FormItem>
<FormItem label="头像">
<Upload
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=person'"
with-credentials
:show-upload-list="false"
:format="['jpg', 'jpeg', 'png', 'webp']"
:on-success="avatarUploaded"
>
<Button icon="ios-cloud-upload-outline">上传头像</Button>
</Upload>
<div v-if="form.avatar" class="avatar-preview-row">
<img :src="form.avatar" class="avatar-preview" />
<Button size="small" type="text" class="avatar-clear" @click="form.avatar = ''">清除</Button>
</div>
<p class="form-tip">建议正方形照片(如 300×300);不上传时首页显示姓名首字圆形徽章</p>
</FormItem>
<FormItem label="所属栏目">
<Select v-model="form.categoryId" clearable placeholder="不选 = 未分组(people 区块选栏目时不会出现)">
<Option v-for="c in flat" :key="c.id" :value="c.id">
<span :style="{ paddingLeft: c.depth * 16 + 'px' }">{{ c.name }}</span>
</Option>
</Select>
</FormItem>
<FormItem label="排序">
<InputNumber v-model="form.sortNo" :min="0" />
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped lang="scss">
.size-tip {
color: #999;
line-height: 40px;
}
.avatar-preview-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
.avatar-preview {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e5e6eb;
}
.avatar-clear {
color: #999;
&:hover {
color: #ed4014;
}
}
}
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
</style>
+112 -2
View File
@@ -21,7 +21,41 @@ const sites = ref<SiteVo[]>([]);
const loading = ref(false); const loading = ref(false);
/** 当前查看的文章(点列表进入详情态) */ /** 当前查看的文章(点列表进入详情态) */
const currentArticle = ref<{ title: string; content: string; publishTime?: string; source?: string } | null>(null); const currentArticle = ref<{ categoryId?: number; title: string; content: string; publishTime?: string; source?: string } | null>(null);
/** 自栏目树求 根→目标 路径(仅启用节点);查不到返回 null */
function findCategoryPath(tree: CategoryTreeNode[], id: number): CategoryTreeNode[] | null {
for (const root of tree) {
if (root.id === id) return [root];
if (!root.children?.length) continue;
const sub = findCategoryPath(root.children, id);
if (sub) return [root, ...sub];
}
return null;
}
/**
* 左侧栏目导航(docs/11 同款规则):模块有二级子栏目时,栏目/文章页左侧
* 展示"模块名 + 全部二级兄弟"。详情态按文章所属栏目定位并高亮;列表态为
* 全站混合列表无具体栏目,取第一个有二级子栏目的模块做形态演示(无高亮)。
*/
const sideNav = computed(() => {
const enabled = categories.value.filter(c => c.status === 1);
let path: CategoryTreeNode[] | null = null;
const currentCatId = currentArticle.value?.categoryId;
if (currentCatId != null) {
path = findCategoryPath(enabled, currentCatId);
}
if (!path) {
const demo = enabled.find(c => c.children?.length);
path = demo ? [demo] : null;
}
if (!path || !path[0].children?.length) return null;
const top = path[0];
const activeId = path.length === 2 ? path[1].id
: path.length > 2 ? path[1].id : null;
return { top, items: top.children.filter(c => c.status === 1), activeId };
});
/** 轮播 */ /** 轮播 */
const bannerIndex = ref(0); const bannerIndex = ref(0);
@@ -92,6 +126,7 @@ async function openArticle(item: ArticlePageItem) {
const detail = await articleApi.get(store.siteId!, item.id).send(); const detail = await articleApi.get(store.siteId!, item.id).send();
if (detail) { if (detail) {
currentArticle.value = { currentArticle.value = {
categoryId: item.categoryId,
title: detail.title, title: detail.title,
content: detail.content || "<p>(无正文)</p>", content: detail.content || "<p>(无正文)</p>",
publishTime: detail.publishTime, publishTime: detail.publishTime,
@@ -117,7 +152,7 @@ onBeforeUnmount(stopCarousel);
<template> <template>
<div class="portal-preview"> <div class="portal-preview">
<div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/</div> <div class="portal-tip">门户预览(简版):正式门户由后端 Thymeleaf 渲染于 /portal/{code}/;有二级栏目的模块,其栏目页/文章页左侧展示兄弟栏目导航(此处同步演示)</div>
<!-- 门户头部 --> <!-- 门户头部 -->
<header class="portal-header"> <header class="portal-header">
@@ -169,6 +204,21 @@ onBeforeUnmount(stopCarousel);
</div> </div>
</div> </div>
<div class="content-cols">
<!-- 左侧栏目导航(docs/11 同款规则):有二级子栏目的模块展示"模块名 + 全部兄弟" -->
<aside class="module-nav" v-if="sideNav">
<div class="module-nav-head">{{ sideNav.top.name }}</div>
<ul>
<li v-for="s in sideNav.items" :key="s.id" :class="{ on: s.id === sideNav.activeId }">
<a
:href="s.type === 3 ? s.linkUrl : 'javascript:;'"
:target="s.type === 3 ? '_blank' : undefined"
rel="noopener noreferrer"
>{{ s.name }}</a>
</li>
</ul>
</aside>
<!-- 文章详情态 --> <!-- 文章详情态 -->
<div class="article-detail" v-if="currentArticle"> <div class="article-detail" v-if="currentArticle">
<a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a> <a class="back-link" href="javascript:;" @click="backToList">← 返回列表</a>
@@ -205,6 +255,7 @@ onBeforeUnmount(stopCarousel);
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 页脚:友情链接 --> <!-- 页脚:友情链接 -->
<footer class="portal-footer" v-if="linkGroups.length"> <footer class="portal-footer" v-if="linkGroups.length">
@@ -388,6 +439,65 @@ onBeforeUnmount(stopCarousel);
padding: 0 16px; padding: 0 16px;
} }
/* 左侧栏目导航 + 内容两栏(docs/11 同款形态) */
.content-cols {
display: flex;
gap: 16px;
align-items: flex-start;
> .portal-columns,
> .article-detail {
flex: 1;
min-width: 0;
}
}
.module-nav {
flex: none;
width: 200px;
background: #fff;
border: 1px solid #e5e6eb;
border-top: 3px solid #2d8cf0;
.module-nav-head {
background: #2d8cf0;
color: #fff;
text-align: center;
font-weight: 700;
padding: 14px 10px;
letter-spacing: 2px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li + li {
border-top: 1px dashed #eee;
}
li a {
display: block;
padding: 10px 14px;
color: #333;
font-size: 14px;
text-decoration: none;
&:hover {
color: #2d8cf0;
background: #f2f6fc;
}
}
li.on a {
background: #2d8cf0;
color: #fff;
font-weight: 600;
}
}
.banner-box { .banner-box {
position: relative; position: relative;
border-radius: 8px; border-radius: 8px;
+19 -68
View File
@@ -6,8 +6,9 @@ import { schoolApi, siteApi, themeApi } from "@/api/module/cms";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms"; import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms";
import { portalUrlOf } from "@/utils/sitePath"; import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import ThemePicker from "@/components/theme/ThemePicker.vue"; import ThemePicker from "@/components/theme/ThemePicker.vue";
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
/** /**
* 学校工作台(一级页,docs/09 §3.3 方案A):全屏无壳。 * 学校工作台(一级页,docs/09 §3.3 方案A):全屏无壳。
@@ -23,10 +24,6 @@ const loading = ref(false);
const themes = ref<ThemeVo[]>([]); const themes = ref<ThemeVo[]>([]);
const isSuper = computed(() => store.isSuperAdmin); const isSuper = computed(() => store.isSuperAdmin);
const userInfo = computed(() => ({
name: store.session.baseUserName || "管理员",
avatar: store.session.baseUserAvatar,
}));
async function run() { async function run() {
loading.value = true; loading.value = true;
@@ -59,21 +56,27 @@ function enter(card: SchoolCard) {
(s: any) => s.id === card.mainSiteId || s.parentSiteId === card.mainSiteId (s: any) => s.id === card.mainSiteId || s.parentSiteId === card.mainSiteId
) )
); );
router.push(`/sch/${card.mainSiteCode}/article`); // 进入学校 = 主站形态(docs/13:URL 没写站点段就是主站)
router.push(adminPathOf(card.mainSiteCode, null, "article"));
} }
function openPortal(card: SchoolCard) { function openPortal(card: SchoolCard) {
window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank"); window.open(`${config.serviceHost}${portalUrlOf({ path: card.mainSitePath, code: card.mainSiteCode })}`, "_blank");
} }
/** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all) */ /** 超管全局站点视图(借道当前/第一个学校上下文 + scope=all):跨校总览落主站形态 */
function goAllSites() { function goAllSites() {
const code = store.schoolCode || cards.value[0]?.mainSiteCode; const code = store.schoolCode || cards.value[0]?.mainSiteCode;
if (code) { if (code) {
router.push({ path: `/sch/${code}/site`, query: { scope: "all" } }); router.push({ path: adminPathOf(code, null, "site"), query: { scope: "all" } });
} }
} }
/** 超管平台级皮肤包管理(不再挂左侧菜单,工作台直达) */
function goThemeManage() {
router.push("/theme-manage");
}
/* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */ /* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */
const modalVisible = ref(false); const modalVisible = ref(false);
const saving = ref(false); const saving = ref(false);
@@ -131,7 +134,9 @@ function onSchoolPick(id: number) {
form.value.schName = opt?.name || ""; form.value.schName = opt?.name || "";
if (opt) { if (opt) {
form.value.name = `${opt.name}门户网站`; form.value.name = `${opt.name}门户网站`;
form.value.code = `sch-${opt.id}`; // R1 学校主站编码规则:1 + 学校ID左补零至4位(总长5位,如 id=13 → 10013);
// id≥10000 时 LPAD 不截断、编码自然超5位(当前学校量级不会触达)
form.value.code = `1${String(opt.id).padStart(4, "0")}`;
} }
} }
@@ -159,30 +164,12 @@ async function create() {
} }
} }
const linkUserInfo = () => {
window.location.href = config.baseHost + "/v/userinfo";
};
const logout = () => {
const url = "/cas/logout?service=" + window.location.href;
window.location.href = config.ssoHost + url;
};
onMounted(run); onMounted(run);
</script> </script>
<template> <template>
<div class="workbench"> <div class="workbench">
<header class="wb-header"> <WorkbenchHeader title="云海CMS · 学校工作台" />
<h4>云海CMS · 学校工作台</h4>
<div class="wb-header-aside">
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="wb-avatar" @click="linkUserInfo" />
<a class="name" href="javascript:void(0);" @click="linkUserInfo">{{ userInfo.name }}</a>
<span class="delimiter"></span>
<a href="javascript:;" class="logout" @click="logout" title="退出登陆">
<i class="iconfont icon-logout"></i>
</a>
</div>
</header>
<!-- 操作工具行(Notice search-wrap 同款灰底条):按钮居左,账号区保持在深色 header 右侧 --> <!-- 操作工具行(Notice search-wrap 同款灰底条):按钮居左,账号区保持在深色 header 右侧 -->
<div class="wb-toolbar" v-if="isSuper"> <div class="wb-toolbar" v-if="isSuper">
@@ -190,6 +177,10 @@ onMounted(run);
<Icon type="ios-globe" /> <Icon type="ios-globe" />
全部站点总览 全部站点总览
</Button> </Button>
<Button class="wb-btn" @click="goThemeManage">
<Icon type="ios-brush" />
皮肤包管理
</Button>
<Button type="primary" class="wb-btn" @click="openAdd"> <Button type="primary" class="wb-btn" @click="openAdd">
<Icon type="ios-add" /> <Icon type="ios-add" />
添加学校 添加学校
@@ -281,46 +272,6 @@ onMounted(run);
flex-direction: column; flex-direction: column;
background: #f5f7fa; background: #f5f7fa;
} }
.wb-header {
height: 56px;
flex-shrink: 0;
background: #1f2a3a;
color: #fff;
display: flex;
align-items: center;
padding: 0 24px;
h4 {
color: #fff;
font-size: 16px;
margin: 0;
}
.wb-header-aside {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
.name {
color: #fff;
}
.delimiter {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.3);
display: inline-block;
}
.wb-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
}
.logout {
color: #fff;
display: inline-flex;
align-items: center;
}
}
}
/* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */ /* 工具行:Notice front .search-wrap 同款(#f7f7f7 底 + 10px 16px 内边距 ≈ 52px 高),按钮居左 */
.wb-toolbar { .wb-toolbar {
flex-shrink: 0; flex-shrink: 0;
+258 -270
View File
@@ -1,30 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import Utils from "@/utils/index"; import Utils from "@/utils/index";
import { assetUrl, siteApi, themeApi } from "@/api/module/cms"; import { assetUrl, siteApi, themeApi } from "@/api/module/cms";
import { portalUrlOf } from "@/utils/sitePath"; import { adminPathOf, portalUrlOf } from "@/utils/sitePath";
import { useCmsStore } from "@/store"; import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import { SiteVo, ThemeVo } from "@/types/cms"; import { SiteVo, ThemeVo } from "@/types/cms";
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson, themeParamsFromConfigJson } from "@/utils/themeParamForm"; import SiteCard from "./components/SiteCard.vue";
import ThemePicker from "@/components/theme/ThemePicker.vue"; import SiteMainCard from "./components/SiteMainCard.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import SiteWizard from "@/views/cms/SiteWizard.vue";
/** /**
* 站点管理(方案 §9.1 + §6.2 主题选择增强)。 * 站点工作台(docs/22 v3,原型 docs/21 屏1):按学校分组 = 主站宽卡 + 院系子站网格,
* 学校化管理(docs/09 §3.3):默认只显示当前学校站点(主站+院系子站); * 站点父子层级用版式分区表达(不靠旧表格「所属主站」列文字);卡片 ⇄ 表格切换 + 名称/编码搜索。
* 超管可经工作台「全部站点总览」(?scope=all)查看全局列表。 * 新建 → 站点设置中心创建模式(site/new);卡/行「站点设置」→ site/setting/:id(docs/16 主线A 收敛不变)。
* 行「进入管理」= 院系子站切换入口(壳面包屑条切换器已按 UX 收敛移除): * 学校化管理(docs/09 §3.3):默认只显示当前学校站点;超管可经工作台「全部站点总览」(?scope=all)。
* 切 store.currentSite 落文章列表,跨校行先重建学校上下文;当前站点行带「当前」标记。 * 「进入管理」= 院系子站切换入口;跨校行先重建学校上下文;当前站带「当前」徽章且按钮禁用。
* 新建 → 三步向导(选模板→微调预览→确认);已建站点 → 编辑弹窗内
* 主题卡片单选 + 参数微调表单,保存带 themeParams(服务端合并进 config_json,方案 §5.3)。
* config_json 结构化编辑见 SiteConfigForm(docs/09 §3.1):
* 已知键分组表单 + 扩展参数动态行,不再手写 JSON;theme_params 仍由 ThemeParamForm 自动合并。
*/ */
const store = useCmsStore(); const store = useCmsStore();
const route = useRoute(); const route = useRoute();
@@ -33,7 +25,7 @@ const router = useRouter();
/** 超管全局视图(工作台入口带入 scope=all) */ /** 超管全局视图(工作台入口带入 scope=all) */
const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin); const scopeAll = computed(() => route.query.scope === "all" && store.isSuperAdmin);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管向导行为不变 */ /** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管同 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool); const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value); const canCreateSite = computed(() => store.isSuperAdmin || isSchoolAdmin.value);
@@ -41,7 +33,7 @@ const rows = ref<SiteVo[]>([]);
const loading = ref(false); const loading = ref(false);
const tableHeight = Utils.useTableHeight({ isOperat: true }); const tableHeight = Utils.useTableHeight({ isOperat: true });
/** 主题元数据映射(列表皮肤列缩略名 + 编辑表单) */ /** 主题元数据映射(列表皮肤列缩略名) */
const themes = ref<ThemeVo[]>([]); const themes = ref<ThemeVo[]>([]);
const themeMeta = computed(() => { const themeMeta = computed(() => {
const map: Record<string, ThemeVo> = {}; const map: Record<string, ThemeVo> = {};
@@ -49,37 +41,91 @@ const themeMeta = computed(() => {
return map; return map;
}); });
/** 视图模式(docs/22 Q2:保留表格,批量/密集场景) */
const view = ref<"card" | "table">("card");
/** 搜索关键字:按 站点名称 / 编码 即时过滤(站点多了找不到的痛点) */
const keyword = ref("");
/** 学校分组:每组 = 主站宽卡 + 该主站的院系子站网格。
* 单校视角只有 1 组;超管 ?scope=all 时按 schId 分组依次排列。 */
interface SiteGroup {
schKey: string;
schName: string;
main: SiteVo | null;
subs: SiteVo[];
/** 子站总数(过滤前):主站卡的「子站」统计要的是真实规模,不随搜索变化 */
rawSubs: number;
}
const groups = computed<SiteGroup[]>(() => {
const kw = keyword.value.trim().toLowerCase();
const match = (s: SiteVo) =>
!kw || s.name.toLowerCase().includes(kw) || String(s.code || "").toLowerCase().includes(kw);
const map = new Map<string, SiteGroup>();
rows.value.forEach(s => {
const key = String(s.schId ?? s.schName ?? "0");
let g = map.get(key);
if (!g) {
g = { schKey: key, schName: s.schName || "", main: null, subs: [], rawSubs: 0 };
map.set(key, g);
}
if (!s.parentSiteId) {
g.main = s;
} else {
g.rawSubs += 1;
// 子站按关键字过滤;但组的主站卡始终保留作层级锚点(不因主站名不匹配而掉链)
if (match(s)) g.subs.push(s);
}
});
// 搜索态:既无命中子站、主站自身也不匹配的组整组隐去
return [...map.values()].filter(g => !kw || g.subs.length > 0 || (g.main && match(g.main)));
});
const columns = [ const columns = [
{ title: "站点编码", key: "code", width: 140 },
{ {
title: "站点名称", title: "站点名称",
key: "name", key: "name",
minWidth: 200, minWidth: 220,
tooltip: true, tooltip: true,
render: (h: any, p: any) => render: (h: any, p: any) =>
h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [ h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [
// 层级徽章前置:主站/院系一眼可辨(取代原本只靠「所属主站」列文字)
h(
"span",
{ class: p.row.parentSiteId ? "tier-bdg sub" : "tier-bdg main" },
p.row.parentSiteId ? "院系" : "主站"
),
h("span", p.row.name), h("span", p.row.name),
// 当前操作站点标记:壳面包屑校名后缀同源(store.currentSite)
p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null, p.row.id === store.siteId ? h(Tag, { color: "success" }, () => "当前") : null,
p.row.status !== 1 ? h(Tag, { color: "default" }, () => "停用") : null,
]), ]),
}, },
{ title: "站点编码", key: "code", width: 120 },
{ title: "学校", key: "schName", width: 150, tooltip: true }, { title: "学校", key: "schName", width: 150, tooltip: true },
{ {
title: "所属主站", title: "所属主站",
key: "parentSiteId", key: "parentSiteId",
width: 150, width: 160,
tooltip: true, tooltip: true,
render: (h: any, p: any) => h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"), render: (h: any, p: any) =>
h("span", p.row.parentSiteId ? p.row.parentName || `ID ${p.row.parentSiteId}` : "顶层站点"),
}, },
{ {
// 200px:让「缩略图 + 名称(包名)」单行放下,消除原 150px 下的 3 行竖排
title: "皮肤包", title: "皮肤包",
key: "theme", key: "theme",
width: 150, width: 200,
render: (h: any, p: any) => { render: (h: any, p: any) => {
const t = themeMeta.value[p.row.theme || "default"]; const t = themeMeta.value[p.row.theme || "default"];
return h("span", { style: "display:inline-flex;align-items:center;gap:6px" }, [ return h("span", { style: "display:inline-flex;align-items:center;gap:7px;white-space:nowrap" }, [
t t
? h("img", { src: assetUrl(t.screenshotUrl), style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee", alt: "" }) ? h("img", {
src: assetUrl(t.screenshotUrl),
style: "width:44px;height:28px;object-fit:cover;border-radius:3px;border:1px solid #eee",
alt: "",
})
: h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }), : h("span", { style: "display:inline-block;width:44px;height:28px;background:#f0f2f5;border-radius:3px" }),
h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"), h("span", t ? `${t.title}(${p.row.theme})` : p.row.theme || "default"),
]); ]);
@@ -92,69 +138,22 @@ const columns = [
width: 90, width: 90,
align: "center", align: "center",
render: (h: any, p: any) => render: (h: any, p: any) =>
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
p.row.status === 1 ? "启用" : "停用"
),
}, },
{ {
title: "操作", title: "操作",
key: "action", key: "action",
width: 150, width: 200,
fixed: "right", fixed: "right",
align: "center", align: "center",
render: (h: any, p: any) => [ render: (h: any, p: any) => [
Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId), Utils.operatingHandle(h, "进入管理", "icon-apps", () => enterManage(p.row), p.row.id === store.siteId || p.row.status !== 1),
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)), Utils.operatingHandle(h, "站点设置", "icon-edit", () => openSetting(p.row)),
Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)), Utils.operatingHandle(h, "门户", "icon-browse", () => openPortal(p.row)),
], ],
}, },
]; ];
/** 新建向导 */
const wizardVisible = ref(false);
/** 编辑弹窗 */
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const configFormRef = ref();
const form = reactive({
id: undefined as number | undefined,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
theme: "default",
configJson: "",
status: 1,
});
const editThemeParams = ref<FormModelValue>({});
const editTheme = computed(() => themeMeta.value[form.theme]);
/** B3 自定义样式(config_json.custom_css):面板为唯一编辑入口,SiteConfigForm 侧该键为保留键 */
const customCss = ref("");
/** 编辑内预览 */
const previewVisible = ref(false);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层) */
const parentOptions = computed(() =>
rows.value.filter(r => !r.parentSiteId && r.id !== form.id)
);
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{
pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/,
message: "仅限小写字母/数字/短横线",
trigger: "blur",
},
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
async function run() { async function run() {
loading.value = true; loading.value = true;
try { try {
@@ -176,107 +175,33 @@ async function loadThemes() {
} }
} }
const editSitePath = ref<string>(""); /** 行「编辑」→ 站点设置中心(docs/16 §3.1):主站形态无站点段,子站带站点段 */
function openSetting(row: SiteVo) {
function openWizard() { const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
wizardVisible.value = true; const schCode = main?.code || store.schoolCode;
if (!schCode) {
Message.warning("无法确定所属学校上下文");
return;
}
router.push(adminPathOf(schCode, row.parentSiteId ? row.code : null, `site/setting/${row.id}`));
} }
/** W1:皮肤包管理页入口 */ /** 新建 → 站点设置中心创建模式;校级建子站、超管建任意站点 */
function goThemeManage() { function openCreate() {
router.push(`/sch/${store.schoolCode}/theme`); const schCode = store.schoolCode || rows.value.find(s => !s.parentSiteId)?.code;
} if (!schCode) {
Message.warning("请先从学校工作台进入后再新建站点");
function onWizardCreated() { return;
run();
}
function openEdit(row: SiteVo) {
editSitePath.value = row.path || row.code;
Object.assign(form, {
id: row.id,
code: row.code,
parentSiteId: row.parentSiteId || undefined,
schId: row.schId,
schName: row.schName || "",
name: row.name,
theme: themeMeta.value[row.theme || "default"] ? row.theme! : "default",
configJson: row.configJson || "",
status: row.status,
});
customCss.value = readCustomCss(form.configJson);
const t = editTheme.value;
editThemeParams.value = t ? themeParamsFromConfigJson(row.configJson, t) : {};
modalVisible.value = true;
}
function readCustomCss(configJson: string): string {
try {
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
} catch {
return "";
}
}
/** 把面板值写入/移出 configJson(空值移除键;与 SiteConfigForm 的 preserved 往返兼容) */
function withCustomCss(configJson: string, css: string): string {
let obj: Record<string, unknown>;
try {
obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
if (!obj || typeof obj !== "object" || Array.isArray(obj)) obj = {};
} catch {
obj = {};
}
if (css.trim()) obj.custom_css = css;
else delete obj.custom_css;
return JSON.stringify(obj);
}
function pickTheme(name: string) {
if (form.theme === name) return;
form.theme = name;
const t = editTheme.value;
editThemeParams.value = t ? formModelFromTheme(t, editThemeParams.value) : {};
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) return;
const configValid = await configFormRef.value?.validate();
if (!configValid) return;
saving.value = true;
try {
const t = editTheme.value;
const themeParams = t ? editThemeParams.value : {};
const baseConfig = withCustomCss(form.configJson || "", customCss.value);
await siteApi
.save({
id: form.id,
code: form.code,
parentSiteId: form.parentSiteId || 0,
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schId: isSchoolAdmin.value ? undefined : form.schId,
schName: isSchoolAdmin.value ? undefined : form.schName || undefined,
name: form.name,
theme: form.theme || undefined,
configJson: t ? mergeThemeParamsIntoConfigJson(baseConfig, t, themeParams) : baseConfig || undefined,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
} }
router.push(adminPathOf(schCode, null, "site/new"));
} }
function openPortal(row: SiteVo) { function openPortal(row: SiteVo) {
window.open(`${config.serviceHost}${portalUrlOf(row)}`, "_blank"); window.open(`${config.serviceHost}${portalUrlOf(row)}`, "_blank");
} }
/** 行级「进入管理」(docs/09 §3.3 子站管理入口):切当前操作站点后落文章列表; /** 行级「进入管理」(docs/13 §5):跳 URL 即完成站点切换——主站形态 /article,
* 院系子站带 /site/{code} 段,store 由 SchoolLayout.syncSiteFromRoute 按 URL 同步;
* 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */ * 全局视图跨校行先重建学校上下文(面包屑/左侧菜单跟随),子站行向上找主站行定学校壳 */
function enterManage(row: SiteVo) { function enterManage(row: SiteVo) {
const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row; const main = row.parentSiteId ? rows.value.find(s => s.id === row.parentSiteId) : row;
@@ -296,12 +221,7 @@ function enterManage(row: SiteVo) {
rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id) rows.value.filter(s => s.id === main.id || s.parentSiteId === main.id)
); );
} }
store.setCurrentSite({ id: row.id, code: row.code, path: row.path || row.code, name: row.name }); router.push(adminPathOf(main.code, row.parentSiteId ? row.code : null, "article"));
router.push(`/sch/${main.code}/article`);
}
function openEditPreview() {
previewVisible.value = true;
} }
/** 全局视图返回学校工作台 */ /** 全局视图返回学校工作台 */
@@ -321,118 +241,186 @@ watch(() => store.currentSchool?.schId, () => run());
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container"> <div class="main-container">
<div class="fix" v-if="scopeAll"> <!-- 超管全局视图(?scope=all):按学校分组,每组 = 主站宽卡 + 子站网格 -->
<Alert type="info" show-icon style="width: 100%"> <Alert v-if="scopeAll" type="info" show-icon style="width: 100%; margin-bottom: 12px">
全局站点视图:显示全部学校的站点(含各校主站与院系子站)。 全局站点视图:按学校分组显示全部站点(含各校主站与院系子站)。
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a> <a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
</Alert> </Alert>
</div>
<div class="fix" v-if="canCreateSite"> <!-- 工具行:新建 + 卡片/表格切换 + 搜索 -->
<Button class="fl" type="primary" size="large" @click="openWizard"> <div class="wb-toolbar mb-12">
<Button v-if="canCreateSite" type="primary" @click="openCreate">
<Icon type="ios-add" /> <Icon type="ios-add" />
{{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }} {{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
</Button>
<!-- W1:皮肤包管理入口(平台菜单注册前兜底可达,方案 §5.4 步骤7/R3) -->
<Button v-if="isSuperAdmin" class="fl" size="large" style="margin-left: 12px" @click="goThemeManage">
<Icon type="ios-color-palette" />
皮肤包管理
</Button> </Button>
<div class="seg">
<button type="button" :class="{ on: view === 'card' }" @click="view = 'card'">卡片</button>
<button type="button" :class="{ on: view === 'table' }" @click="view = 'table'">表格</button>
</div>
<div class="seg-search">
<Input v-model="keyword" placeholder="搜索站点名称 / 编码" clearable style="width: 220px" />
</div>
</div> </div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
<!-- 编辑弹窗 --> <Spin v-if="loading" size="large" fix />
<Modal v-model="modalVisible" title="编辑站点" :mask-closable="false" width="720px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="110"> <template v-else>
<FormItem label="站点编码" prop="code"> <!-- 卡片视图:主站宽卡 + 院系子站网格 -->
<Input v-model="form.code" :disabled="!!form.id" placeholder="URL 路径用,如 demo" /> <div v-if="view === 'card'">
</FormItem> <template v-if="groups.length">
<FormItem label="站点名称" prop="name"> <section v-for="g in groups" :key="g.schKey" class="site-group">
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" /> <h3 v-if="scopeAll && (g.schName || g.main?.name)" class="group-cap">
</FormItem> {{ g.schName || g.main?.name }}
<FormItem label="上级站点"> </h3>
<Select v-model="form.parentSiteId" :clearable="!isSchoolAdmin" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option> <SiteMainCard
</Select> v-if="g.main"
<p class="form-tip">院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}</p> :site="g.main"
</FormItem> :is-current="g.main.id === store.siteId"
<FormItem v-if="store.isSuperAdmin" label="学校ID"> :theme="themeMeta[g.main.theme || 'default']"
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" /> :sub-count="g.rawSubs"
</FormItem> @enter="enterManage"
<FormItem v-if="store.isSuperAdmin" label="学校名称"> @setting="openSetting"
<Input v-model="form.schName" :maxlength="128" /> @portal="openPortal"
</FormItem>
<FormItem label="皮肤包">
<ThemePicker :themes="themes" :model-value="form.theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openEditPreview">预览本皮肤</Button>
<ThemeParamForm :theme="editTheme" v-model="editThemeParams" />
<p class="form-tip">换肤/改参数保存后门户立即生效(缓存已自动失效)。</p>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<!-- B3 自定义样式(config_json.custom_css,服务端黑名单强校验) -->
<Collapse class="css-collapse">
<Panel name="css" header="自定义样式(站点级 CSS 覆盖)">
<Input
v-model="customCss"
type="textarea"
:rows="6"
:maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor"
/> />
<div class="css-meta">
<span>{{ customCss.length }}/10240 字符</span> <div class="zone-cap">
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span> <h3>院系子站</h3>
<span class="cnt">{{ g.subs.length }} 个</span>
</div> </div>
</Panel>
</Collapse> <div v-if="g.subs.length" class="card-grid">
<!-- 站点配置结构化表单(独立 Form 分区,v-model 直绑 configJson) --> <SiteCard
<SiteConfigForm ref="configFormRef" v-model="form.configJson" /> v-for="s in g.subs"
<template #footer> :key="s.id"
<Button @click="modalVisible = false">取消</Button> :site="s"
<Button type="primary" :loading="saving" @click="save">保存</Button> :is-current="s.id === store.siteId"
:theme="themeMeta[s.theme || 'default']"
@enter="enterManage"
@setting="openSetting"
@portal="openPortal"
/>
</div>
<div v-else class="empty-state">
<p v-if="keyword">没有匹配「{{ keyword }}」的院系子站。</p>
<p v-else>本校暂无院系子站。</p>
<Button v-if="canCreateSite && !keyword" type="primary" @click="openCreate">
<Icon type="ios-add" />
新建院系子站
</Button>
</div>
</section>
</template> </template>
</Modal> <div v-else class="empty-state">
<p v-if="keyword">没有匹配「{{ keyword }}」的站点。</p>
<p v-else>暂无站点,请联系平台管理员在学校工作台添加。</p>
</div>
</div>
<!-- 编辑预览弹窗(iframe 加载 /api/theme/preview,no-store) --> <!-- 表格视图:保留批量速览(列已重排) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide> <Table
<ThemePreviewFrame v-if="previewVisible && editTheme" :site-path="editSitePath" :theme="form.theme" :theme-params="editThemeParams" /> v-else
</Modal> class="link-table table-bottom-box"
:columns="columns"
<!-- 新建站点三步向导(B1:校级锁定为本校主站下建院系子站) --> :data="rows"
<SiteWizard v-model:visible="wizardVisible" :sites="rows" :lock-parent-id="isSchoolAdmin ? store.currentSchool?.mainSiteId : undefined" @created="onWizardCreated" /> :height="tableHeight"
:loading="loading"
no-data-text="暂无站点"
/>
</template>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.form-tip { .wb-toolbar {
color: #999;
font-size: 12px;
line-height: 1.6;
}
.preview-btn {
margin: 8px 0 0;
}
.css-collapse {
margin: 4px 0 12px;
.css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
.css-meta {
display: flex; display: flex;
justify-content: space-between; align-items: center;
gap: 12px; gap: 12px;
margin-top: 6px; }
color: #999; /* 卡片 ⇄ 表格 视图切换(原型同款分段控件) */
.seg {
display: inline-flex;
border: 1px solid #dcdee2;
border-radius: 4px;
overflow: hidden;
background: #fff;
button {
border: none;
background: transparent;
padding: 6px 14px;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
color: #515a6e;
font-family: inherit;
&.on {
background: #eff4ff;
color: #4465d4;
font-weight: bold;
}
}
}
.seg-search {
margin-left: auto;
}
.site-group + .site-group {
margin-top: 34px;
padding-top: 26px;
border-top: 1px solid #eef0f3;
}
.group-cap {
margin: 0 0 14px;
font-size: 15px;
color: #17233d;
}
.zone-cap {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 12px;
h3 {
margin: 0;
font-size: 16px;
color: #17233d;
}
.cnt {
font-size: 12px; font-size: 12px;
line-height: 1.6; color: #808695;
}
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(276px, 1fr));
gap: 16px;
}
.empty-state {
border: 1px dashed #dcdee2;
border-radius: 8px;
background: #fff;
padding: 44px 0;
text-align: center;
color: #808695;
p {
margin: 0 0 16px;
font-size: 13px;
}
}
/* 表格视图的层级徽章(render 出的 span,scoped 属性会下发到该节点) */
:deep(.tier-bdg) {
display: inline-block;
flex-shrink: 0;
white-space: nowrap;
font-size: 10px;
line-height: 16px;
padding: 0 5px;
border-radius: 3px;
color: #fff;
&.main {
background: #577afb;
}
&.sub {
background: #ff9900;
} }
} }
</style> </style>
+783
View File
@@ -0,0 +1,783 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms";
import config from "@/assets/config/host.config";
import { portalUrlOf, adminPathOf, previewPortalUrl } from "@/utils/sitePath";
import { useCmsStore } from "@/store";
import { SiteVo, ThemeVo } from "@/types/cms";
import { resolveEditableTheme } from "@/utils/themeEdit";
import { mergeConfigPatch } from "@/utils/siteConfigForm";
import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
/**
* 站点设置中心(docs/16 主线A 整页化 → docs/20 F1 Tab 化 + 渐进式向导):
* 编辑模式 = 顶部 7 Tab(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO 与域名/自定义样式),
* 每个 Tab 独立表单、独立 dirty 状态、独立保存——保存只合并本 Tab 的键空间进
* lastSavedConfigJson(服务端事实源),其他 Tab 的未保存修改既不丢失也不被连带提交。
* 新建模式 = 渐进式向导:第一步只填基本信息,创建成功自动进入第二步(选皮肤),
* 完成后提示并展开全部 Tab;不再有任何「创建站点后开放」灰态锁定。
*/
const store = useCmsStore();
const route = useRoute();
const router = useRouter();
const siteIdParam = computed(() => (route.params.id ? Number(route.params.id) : undefined));
const isCreate = computed(() => siteIdParam.value == null);
/** B1 建站权限拆分:校级(学校上下文内)可自助创建院系子站;超管行为不变 */
const isSchoolAdmin = computed(() => !store.isSuperAdmin && !!store.currentSchool);
/** 校级锁定在本校主站下建子站(原向导 lockParentId 语义) */
const lockParentId = computed(() => (isCreate.value && isSchoolAdmin.value ? store.currentSchool?.mainSiteId : undefined));
const loading = ref(true);
const loadError = ref("");
const row = ref<SiteVo | null>(null);
const sites = ref<SiteVo[]>([]);
const themes = ref<ThemeVo[]>([]);
// ===== 向导(F1):1=填基本信息(创建模式);2=选皮肤(创建成功后);0=常规编辑 =====
const wizardStep = ref(isCreate.value ? 1 : 0);
// ===== Tab 结构(F1:7 Tab;自定义样式独立成 Tab)=====
const TABS = [
{ key: "basic", title: "基本信息" },
{ key: "theme", title: "皮肤与外观" },
{ key: "layout", title: "首页布局" },
{ key: "display", title: "基础展示" },
{ key: "footer", title: "页脚信息" },
{ key: "seo", title: "SEO 与域名" },
{ key: "css", title: "自定义样式" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const activeTab = ref<string>("basic");
/** 服务端事实源:最近一次保存成功(或载入)时的 configJson —— 各 Tab 保存以此为基础做增量合并 */
const lastSavedConfigJson = ref("");
/** ① 基本信息表单 */
const basicRef = ref();
const basic = reactive({
id: undefined as number | undefined,
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
status: 1,
});
/** ② 皮肤与外观(工作项 T:参数微调已迁皮肤包管理,站点只负责选哪套包) */
const theme = ref("default");
const themeUnregistered = ref(false);
const previewVisible = ref(false);
const editTheme = computed(() => themes.value.find(t => t.name === theme.value));
/** ⑦ 自定义样式(F1:独立 Tab,config_json.custom_css,服务端黑名单强校验) */
const customCss = ref("");
/** ③~⑥ 分区表单:各持独立模型(seed 后不再互相同步,保存各自合并) */
const layoutEditorRef = ref();
const layoutModel = ref("");
const displayFormRef = ref();
const displayModel = ref("");
const footerFormRef = ref();
const footerModel = ref("");
const seoFormRef = ref();
const seoModel = ref("");
/** 各 Tab 独立 dirty 状态(切 Tab 不丢;仅本 Tab 保存后清除) */
const dirty = reactive<Record<string, boolean>>({
basic: false, theme: false, layout: false, display: false, footer: false, seo: false, css: false,
});
const saving = reactive<Record<string, boolean>>({});
/** initFromRow / 保存回灌种子时挂起 dirty 标记 */
let seeding = false;
function tabLabel(tab: { key: string; title: string }): string {
return dirty[tab.key] ? `${tab.title} ●` : tab.title;
}
const schoolScopedSites = computed(() =>
!store.currentSchool ? sites.value : sites.value.filter(s => s.schId === store.currentSchool!.schId)
);
/** 可选主站:顶层站点且非自身(院系子站层级固定两层);校级锁定本校主站 */
const parentOptions = computed(() => {
if (lockParentId.value) {
return sites.value.filter(s => s.id === lockParentId.value && !s.parentSiteId);
}
return schoolScopedSites.value.filter(s => !s.parentSiteId && s.id !== basic.id);
});
const parentSiteOfBasic = computed(() => sites.value.find(s => s.id === basic.parentSiteId));
const portalPreviewUrl = computed(() =>
isCreate.value
? previewPortalUrl(parentSiteOfBasic.value?.path ?? parentSiteOfBasic.value?.code, basic.code)
: row.value
? portalUrlOf(row.value)
: ""
);
/** F2 实时预览 iframe 地址(编辑模式 = serviceHost + 正式门户首页) */
const portalFrameUrl = computed(() =>
!isCreate.value && row.value ? `${config.serviceHost}${portalUrlOf(row.value)}` : undefined
);
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
/** B-8:顶层站 schId 必填(async-validator 默认按 string,数字必选须 type:"number");动态规则挂 FormItem */
const schIdRules = [{ required: true, type: "number" as const, message: "顶层站点必须指定学校ID", trigger: "change" }];
const schIdRequired = computed(() => store.isSuperAdmin && !basic.parentSiteId);
onMounted(async () => {
await Promise.all([loadSites(), loadThemes()]);
if (!isCreate.value) {
initFromRow(siteIdParam.value!);
} else if (lockParentId.value) {
basic.parentSiteId = lockParentId.value;
}
loading.value = false;
});
async function loadSites() {
sites.value = (await siteApi.list().send()) || [];
}
async function loadThemes() {
try {
themes.value = (await themeApi.list().send()) || [];
} catch {
themes.value = [];
}
}
/** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */
async function initFromRow(id: number) {
const r = sites.value.find(s => s.id === id);
if (!r) {
loadError.value = "站点不存在或无权管理该站点";
return;
}
seeding = true;
row.value = r;
// 主题值保留原值(未注册时告警,由服务端校验拒绝),不再静默改写为 default
const resolved = resolveEditableTheme(r.theme, themes.value.map(t => t.name));
themeUnregistered.value = resolved.unregistered;
Object.assign(basic, {
id: r.id,
code: r.code,
parentSiteId: r.parentSiteId || undefined,
schId: r.schId,
schName: r.schName || "",
name: r.name,
status: r.status,
});
theme.value = resolved.theme;
lastSavedConfigJson.value = r.configJson || "";
layoutModel.value = lastSavedConfigJson.value;
displayModel.value = lastSavedConfigJson.value;
footerModel.value = lastSavedConfigJson.value;
seoModel.value = lastSavedConfigJson.value;
customCss.value = readCustomCss(lastSavedConfigJson.value);
Object.keys(dirty).forEach(k => (dirty[k] = false));
// docs/20 返工 #2:seeding 是同步布尔位,而 Vue watcher 异步 flush——种值触发的 watcher 回调
// 落在下一个 flush 周期(此时同步代码早已把 seeding 置回 false),必须等它们在挂起窗口内
// 跑完再放行,否则冷加载即假脏
await nextTick();
seeding = false;
}
function readCustomCss(configJson: string): string {
try {
const obj = configJson && configJson.trim() ? JSON.parse(configJson) : {};
return obj && typeof obj === "object" && typeof obj.custom_css === "string" ? obj.custom_css : "";
} catch {
return "";
}
}
/** HomeLayoutEditor 的整份 config 输出 → 仅取 home_layout 键的增量(无布局=删除键) */
function layoutPatchOf(model: string): { home_layout?: unknown } {
try {
const parsed = model && model.trim() ? JSON.parse(model) : {};
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { home_layout: (parsed as Record<string, unknown>)["home_layout"] };
}
} catch {
// 非法 JSON 视为无变更
}
return {};
}
function pickTheme(name: string) {
if (theme.value === name) return;
theme.value = name;
themeUnregistered.value = false;
dirty.theme = true;
}
function openPreview() {
previewVisible.value = true;
}
/**
* 统一提交(F1 Tab 独立保存):site 字段 = 当前基本信息快照 + 当前皮肤;
* configJson = lastSavedConfigJson + 本 Tab 增量 patch(patch=null = 不动 config)。
* 成功后回写事实源并清本 Tab dirty;其他 Tab 的内存修改不受影响。
*/
async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
saving[tab] = true;
try {
const configJson = patch == null
? lastSavedConfigJson.value || undefined
: mergeConfigPatch(lastSavedConfigJson.value, patch);
const res = await siteApi
.save({
id: basic.id,
code: basic.code,
parentSiteId: basic.parentSiteId || 0,
// B1:校级角色隐藏学校信息字段,留空由服务端继承主站(防伪造通道)
schId: isSchoolAdmin.value ? undefined : basic.schId,
schName: isSchoolAdmin.value ? undefined : basic.schName || undefined,
name: basic.name,
theme: theme.value || undefined,
configJson,
status: basic.status,
})
.send();
if (isCreate.value && res && res.id) {
// 向导第一步完成:原地 replace 解锁全部 Tab,自动进入第二步(选皮肤)
Message.success("站点创建成功!接下来为它挑选一套皮肤");
await loadSites();
const main = sites.value.find(s => s.id === (res.parentSiteId || 0));
const schCode = main?.code || res.code;
await router.replace(adminPathOf(schCode, res.parentSiteId ? res.code : null, `site/setting/${res.id}`));
initFromRow(res.id);
wizardStep.value = 2;
activeTab.value = "theme";
return;
}
Message.success("保存成功!");
if (res) {
row.value = res;
if (typeof res.configJson === "string") {
lastSavedConfigJson.value = res.configJson;
}
}
dirty[tab] = false;
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 窗口须覆盖异步 flush 的 watcher,返工 #2 同 initFromRow)
seeding = true;
if (tab === "layout") {
layoutModel.value = lastSavedConfigJson.value;
layoutEditorRef.value?.refreshAfterSave?.();
} else if (tab === "css") {
customCss.value = readCustomCss(lastSavedConfigJson.value);
}
await nextTick();
seeding = false;
} finally {
saving[tab] = false;
}
}
async function saveBasic() {
const valid = await basicRef.value?.validate();
if (!valid) {
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
return;
}
await persistTab("basic", null);
}
async function saveTheme() {
await persistTab("theme", null);
}
/** 向导第二步收口:保存皮肤 → 提示可继续配置首页布局,展开全部 Tab */
async function finishWizard() {
await persistTab("theme", null);
wizardStep.value = 0;
Message.success("站点创建成功,可继续配置首页布局");
}
/** 向导第二步跳过(皮肤先用当前选择/默认) */
function skipWizard() {
wizardStep.value = 0;
Message.info("已跳过皮肤配置,全部设置已开放");
}
async function saveLayout() {
const err = layoutEditorRef.value?.validate();
if (err) {
Message.error(err);
return;
}
await persistTab("layout", layoutPatchOf(layoutModel.value));
}
/** 分区表单 ref 表(docs/20 返工 #4:模板实参里的 ref 会被 Vue 自动解包成组件实例,
* 旧代码按 `formRef.value?.validate()` 调用恒为 undefined → validate 恒 false;refs 必须在脚本侧取用) */
const groupFormRefs: Record<"display" | "footer" | "seo", typeof displayFormRef> = {
display: displayFormRef,
footer: footerFormRef,
seo: seoFormRef,
};
async function saveGroupForm(tab: "display" | "footer" | "seo") {
const form = groupFormRefs[tab].value;
const firstError = await form?.validate();
if (firstError) {
// 精确到「字段名:原因」(内联红框 + 字段下方文案 + 滚动定位由 SiteConfigForm.validate 承担),
// 不再用「请填写红色星号内容」这类与实际校验机制不匹配的通用提示
Message.warning(firstError);
return;
}
await persistTab(tab, form?.ownFields?.() || {});
}
async function saveCss() {
await persistTab("css", { custom_css: customCss.value });
}
/** 门户预览(编辑模式):新窗口打开真实门户 */
function openPortal() {
if (!row.value) return;
window.open(`${config.serviceHost}${portalUrlOf(row.value)}`, "_blank");
}
function backToList() {
const main = row.value?.parentSiteId ? sites.value.find(s => s.id === row.value!.parentSiteId) : row.value;
const schCode = main?.code || store.schoolCode;
if (!schCode) {
router.push("/workbench");
return;
}
router.push(adminPathOf(schCode, row.value?.parentSiteId ? row.value.code : null, "site"));
}
// ===== dirty 追踪:各 Tab 模型变更即标脏(seeding 期间挂起)=====
watch(basic, () => {
if (!seeding) dirty.basic = true;
});
watch(customCss, () => {
if (!seeding) dirty.css = true;
});
watch(layoutModel, () => {
if (!seeding) dirty.layout = true;
});
watch(displayModel, () => {
if (!seeding) dirty.display = true;
});
watch(footerModel, () => {
if (!seeding) dirty.footer = true;
});
watch(seoModel, () => {
if (!seeding) dirty.seo = true;
});
// 学校上下文切换即时重载(编辑中的站点归属变化防御)
watch(() => store.currentSchool?.schId, () => loadSites());
</script>
<template>
<div class="site-setting">
<div class="main-container">
<!-- 页头 -->
<div class="ss-header">
<div class="ss-title">
<h3>{{ isCreate ? (isSchoolAdmin ? "新建院系子站" : "新建站点") : basic.name || "站点设置" }}</h3>
<template v-if="!isCreate">
<Tag>{{ basic.code }}</Tag>
<Tag :color="basic.status === 1 ? 'success' : 'default'">{{ basic.status === 1 ? "启用" : "停用" }}</Tag>
</template>
</div>
<div class="ss-actions">
<Button v-if="!isCreate" icon="ios-browsers" @click="openPortal">门户预览</Button>
<Button icon="md-arrow-back" @click="backToList">返回列表</Button>
</div>
</div>
<Alert v-if="loadError" type="error" show-icon style="margin-bottom: 12px">{{ loadError }}</Alert>
<Spin v-if="loading" size="large" fix />
<!-- 渐进式向导进度(F1):仅创建流程期间展示 -->
<Steps v-if="wizardStep > 0" :current="wizardStep" class="ss-steps">
<Step title="基本信息" content="编码 / 名称 / 归属"></Step>
<Step title="皮肤与外观" content="挑一套门户皮肤"></Step>
<Step title="完整设置" content="首页布局等全部配置开放"></Step>
</Steps>
<!-- ===== 向导第一步(创建模式):仅基本信息表单 ===== -->
<div v-if="wizardStep === 1" class="card setting-card">
<div class="card-header">
<span class="card-title">基本信息</span>
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
</div>
<div class="card-body">
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="basic.code" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" />
</FormItem>
<FormItem label="上级站点">
<Select v-model="basic.parentSiteId" :clearable="!lockParentId" :disabled="!!lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<p class="form-hint">
院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。
{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}
</p>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校ID" prop="schId" :rules="schIdRequired ? schIdRules : []">
<InputNumber v-model="basic.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<div v-if="schIdRequired" class="form-hint">顶层站点必填(否则校级角色不可管理);院系子站可留空自动继承主站</div>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
</FormItem>
<FormItem label="门户地址预览">
<span class="ss-portal-url">{{ portalPreviewUrl || "输入编码后自动生成" }}</span>
<div class="form-hint">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达</div>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="basic.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.basic" @click="saveBasic">创建站点,下一步</Button>
<span class="card-hint">创建后自动进入第二步挑选皮肤,其余配置随即全部开放</span>
</div>
</div>
<!-- ===== 常规编辑(含向导第二步起):顶部 Tab,7 个分区各持独立表单与保存 ===== -->
<Tabs v-else v-model="activeTab" class="ss-tabs" :animated="false">
<TabPane :label="tabLabel(TABS[0])" name="basic">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">基本信息</span>
<span class="card-subtitle">站点编码 / 名称 / 归属与状态</span>
</div>
<div class="card-body">
<Form ref="basicRef" :model="basic" :rules="rules" :label-width="110">
<FormItem label="站点编码" prop="code">
<Input v-model="basic.code" :disabled="!isCreate" placeholder="URL 路径用,如 demo" style="max-width: 320px" />
<div v-if="!isCreate" class="form-hint">编码创建后不可修改(URL 末段)</div>
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="basic.name" :maxlength="128" placeholder="如 XX学校门户网站" style="max-width: 320px" />
</FormItem>
<FormItem label="上级站点">
<Select v-model="basic.parentSiteId" :clearable="!lockParentId" :disabled="!!lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<p class="form-hint">
院系子站挂在主站下,门户 header 右上出现"专业院系"下拉;层级固定两层。
{{ isSchoolAdmin ? "校级角色仅可在本校主站之间改挂。" : "" }}
</p>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校ID" prop="schId" :rules="schIdRequired ? schIdRules : []">
<InputNumber v-model="basic.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<div v-if="schIdRequired" class="form-hint">顶层站点必填(否则校级角色不可管理);院系子站可留空自动继承主站</div>
</FormItem>
<FormItem v-if="store.isSuperAdmin" label="学校名称">
<Input v-model="basic.schName" :maxlength="128" style="max-width: 320px" />
</FormItem>
<FormItem label="门户地址">
<span class="ss-portal-url">{{ portalPreviewUrl || "-" }}</span>
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="basic.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.basic" @click="saveBasic">保存基本信息</Button>
<span class="card-hint">仅保存本 Tab;其他 Tab 未保存的修改保留在各自表单里,不会连带提交</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[1])" name="theme">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">皮肤与外观</span>
<span class="card-subtitle">风格选择(皮肤参数的调整入口在「皮肤包管理」;自定义 CSS 已独立为专属 Tab)</span>
</div>
<div class="card-body">
<div class="field-block">
<label class="field-label">皮肤包</label>
<Alert v-if="themeUnregistered" type="warning" show-icon style="margin-bottom: 8px">
皮肤包「{{ theme }}」未在注册表中,保存将被服务端拒绝;请先由平台超管上传该皮肤包,或改选下方其他皮肤。
</Alert>
<Alert v-else-if="!themes.length" type="warning" show-icon style="margin-bottom: 8px">
皮肤包列表加载失败,暂时无法选择;站点当前皮肤包({{ theme }})保持不变,可稍后重试。
</Alert>
<ThemePicker :themes="themes" :model-value="theme" @update:model-value="pickTheme" />
<Button size="small" class="preview-btn" :disabled="!editTheme" @click="openPreview">预览本皮肤</Button>
</div>
</div>
<div class="card-footer">
<template v-if="wizardStep === 2">
<Button type="primary" :loading="saving.theme" @click="finishWizard">保存并完成</Button>
<Button @click="skipWizard">跳过,稍后配置</Button>
<span class="card-hint">第二步(选皮肤)完成后全部设置开放</span>
</template>
<template v-else>
<Button type="primary" :loading="saving.theme" @click="saveTheme">保存皮肤与外观</Button>
<span class="card-hint">换肤保存后门户立即生效(缓存已自动失效)</span>
</template>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[2])" name="layout">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">首页布局</span>
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
</div>
<div class="card-body">
<HomeLayoutEditor
v-model="layoutModel"
:site-id="basic.id"
:portal-url="portalFrameUrl"
ref="layoutEditorRef"
@save="saveLayout"
/>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
<span class="card-hint">保存后门户立即生效(缓存自动失效);未编排布局时门户按「全部栏目自动渲染」默认形态展示</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[3])" name="display">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">基础展示</span>
<span class="card-subtitle">Logo / favicon / 标语 / 院系文案(图片支持直接上传)</span>
</div>
<div class="card-body">
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.display" @click="saveGroupForm('display')">保存基础展示</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[4])" name="footer">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">页脚信息</span>
<span class="card-subtitle">联系方式 / 多校区地址 / 二维码 / 社媒 / 版权备案</span>
</div>
<div class="card-body">
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer')">保存页脚信息</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[5])" name="seo">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">SEO 与域名</span>
<span class="card-subtitle">搜索引擎摘要 / 独立域名台账与对外基地址</span>
</div>
<div class="card-body">
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo')">保存 SEO 与域名</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
<TabPane :label="tabLabel(TABS[6])" name="css">
<div class="card setting-card">
<div class="card-header">
<span class="card-title">自定义样式</span>
<span class="card-subtitle">站点级 CSS 覆盖(面向需要微调的高级用户)</span>
</div>
<div class="card-body">
<Input
v-model="customCss"
type="textarea"
:rows="14"
:maxlength="10240"
placeholder="如:--primary:#59aeeb; 或 .site-header{height:80px}"
class="css-editor"
/>
<div class="css-meta">
<span>{{ customCss.length }}/10240 字符</span>
<span>保存后门户立即生效(缓存自动失效);服务端将拒绝 &lt;/style、&lt;script、@import、expression( 等危险内容</span>
</div>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.css" @click="saveCss">保存自定义样式</Button>
<span class="card-hint">仅保存本 Tab 的字段</span>
</div>
</div>
</TabPane>
</Tabs>
</div>
</div>
<!-- 皮肤预览弹窗(iframe 加载 /api/theme/preview,no-store) -->
<Modal v-model="previewVisible" title="皮肤预览" :mask-closable="true" width="980px" footer-hide>
<ThemePreviewFrame
v-if="previewVisible && editTheme && row"
:site-path="row.path || row.code"
:theme="theme"
:theme-params="editTheme?.paramValues || {}"
/>
</Modal>
</template>
<style scoped lang="scss">
.site-setting {
position: relative;
min-height: 400px;
.ss-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
.ss-title {
display: flex;
align-items: center;
gap: 10px;
h3 {
margin: 0;
}
}
}
.ss-steps {
margin-bottom: 18px;
background: #fff;
border: 1px solid #e9edf5;
border-radius: 10px;
padding: 14px 24px 4px;
}
.ss-tabs {
background: #fff;
border: 1px solid #e9edf5;
border-radius: 10px;
padding: 4px 16px 16px;
:deep(.ivu-tabs-nav) {
margin-bottom: 12px;
}
}
.setting-card {
border: 1px solid #f0f2f5;
border-radius: 10px;
background: #fff;
padding: 18px 20px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
.card-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f2f5;
.card-title {
position: relative;
font-size: 15px;
font-weight: 600;
color: #1e2635;
padding-left: 10px;
&::before {
content: "";
position: absolute;
left: 0;
top: 3px;
bottom: 3px;
width: 3px;
border-radius: 2px;
background: #577afb;
}
}
.card-subtitle {
font-size: 12px;
color: #8a93a6;
}
}
.card-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f0f2f5;
.card-hint {
flex: 1;
min-width: 0;
color: #8a93a6;
font-size: 12px;
line-height: 1.6;
}
}
}
.form-hint {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.field-block {
margin-bottom: 12px;
.field-label {
display: block;
font-size: 13px;
color: #515a6e;
margin-bottom: 8px;
font-weight: 600;
}
}
.ss-portal-url {
color: #2d8cf0;
font-family: Consolas, Menlo, monospace;
word-break: break-all;
}
.preview-btn {
margin: 8px 0 0;
}
.css-editor :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
line-height: 1.7;
}
.css-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: #999;
font-size: 12px;
line-height: 1.6;
}
}
</style>
-314
View File
@@ -1,314 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms";
import { SiteVo, ThemeVo } from "@/types/cms";
import { formModelFromTheme, FormModelValue, mergeThemeParamsIntoConfigJson } from "@/utils/themeParamForm";
import { previewPortalUrl } from "@/utils/sitePath";
import ThemePicker from "@/components/theme/ThemePicker.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
/**
* 新建站点三步向导(方案 §6.1):①选模板 → ②微调+实时预览 → ③站点信息+确认。
* 预览内容源:当前启用站点(默认第一个);无启用站点时隐藏预览并提示。
* 向导中途关闭不产生任何数据;确认后创建成功才落库。
*/
const props = defineProps<{
visible: boolean;
sites: SiteVo[];
/** B1 建站权限拆分:校级锁定在本校主站下建院系子站(上级禁改、学校信息由服务端继承) */
lockParentId?: number;
}>();
const emit = defineEmits<{
(e: "update:visible", v: boolean): void;
(e: "created"): void;
}>();
const step = ref(1);
const themes = ref<ThemeVo[]>([]);
const themesLoading = ref(false);
const form = reactive({
code: "",
parentSiteId: undefined as number | undefined,
schId: undefined as number | undefined,
schName: "",
name: "",
status: 1,
configJson: "",
});
const themeName = ref("default");
const themeParams = ref<FormModelValue>({});
const sourceSitePath = ref<string>("");
const saving = ref(false);
/** 内容源候选:启用中的站点(顶层/子站皆可作内容源) */
const sourceSites = computed(() => props.sites.filter(s => s.status === 1));
const selectedTheme = computed(() => themes.value.find(t => t.name === themeName.value));
const canPreview = computed(() => !!sourceSitePath.value && !!themeName.value);
const previewTick = ref(0);
const previewParams = ref<FormModelValue>({});
/** 锁定模式:上级仅本校主站;否则全部顶层站可选 */
const parentOptions = computed(() =>
props.lockParentId
? props.sites.filter(s => s.id === props.lockParentId && !s.parentSiteId)
: props.sites.filter(s => !s.parentSiteId)
);
/** W4(G5):门户地址预览——随上级与编码输入联动(子站=上级地址/编码;顶层=编码) */
const wizardParent = computed(() =>
form.parentSiteId ? props.sites.find(s => s.id === form.parentSiteId) : undefined
);
const previewUrl = computed(() =>
previewPortalUrl(wizardParent.value?.path ?? wizardParent.value?.code, form.code)
);
function debouncePreview() {
previewParams.value = { ...themeParams.value };
previewTick.value = Date.now();
}
watch(themeName, async () => {
const t = selectedTheme.value;
if (t) themeParams.value = formModelFromTheme(t);
debouncePreview();
});
watch(themeParams, debouncePreview, { deep: true });
watch(
() => props.visible,
async v => {
if (!v) return;
step.value = 1;
Object.assign(form, { code: "", parentSiteId: props.lockParentId, schId: undefined, schName: "", name: "", status: 1, configJson: "" });
sourceSitePath.value = sourceSites.value[0]?.path || sourceSites.value[0]?.code || "";
if (!themes.value.length) await loadThemes();
themeName.value = "default";
const t = selectedTheme.value;
themeParams.value = t ? formModelFromTheme(t) : {};
debouncePreview();
},
);
async function loadThemes() {
themesLoading.value = true;
try {
themes.value = (await themeApi.list().send()) || [];
if (!themes.value.some(t => t.name === "default") && themes.value.length) {
themeName.value = themes.value[0].name;
}
} finally {
themesLoading.value = false;
}
}
function pickTheme(name: string) {
themeName.value = name;
}
function next() {
// 站点信息表单在第 3 步才挂载,步进本身不校验;校验放在「创建站点」时(见 createSite)
step.value += 1;
}
function prev() {
if (step.value > 1) step.value -= 1;
}
const formRef = ref();
const configFormRef = ref();
const rules = {
code: [
{ required: true, message: "站点编码不得为空", trigger: "blur" },
{ pattern: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/, message: "仅限小写字母/数字/短横线", trigger: "blur" },
],
name: [{ required: true, message: "站点名称不得为空", trigger: "blur" }],
};
async function createSite() {
const valid = await formRef.value?.validate();
if (!valid) return;
const configValid = await configFormRef.value?.validate();
if (!configValid) return;
saving.value = true;
try {
const configJson = mergeThemeParamsIntoConfigJson(form.configJson || "", selectedTheme.value!, themeParams.value);
await siteApi
.save({
code: form.code,
parentSiteId: form.parentSiteId || 0,
// B1:校级锁定模式学校信息留空,服务端校验后继承主站(防伪造)
schId: props.lockParentId ? undefined : form.schId,
schName: props.lockParentId ? undefined : form.schName || undefined,
name: form.name,
theme: themeName.value,
configJson,
status: form.status,
})
.send();
Message.success("站点创建成功!");
emit("update:visible", false);
emit("created");
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal
:model-value="visible"
:title="`${props.lockParentId ? '新建院系子站' : '新建站点'}向导 · 第 ${step} / 3 步`"
:mask-closable="false"
width="980px"
@update:model-value="emit('update:visible', $event)"
@on-cancel="emit('update:visible', false)"
>
<div class="wizard">
<!-- ① 选模板 -->
<div v-if="step === 1" class="wizard-step">
<p class="step-tip">选择一个皮肤包作为网站风格(后续可在站点管理中微调主色等参数)。</p>
<ThemePicker v-if="themes.length" :themes="themes" :model-value="themeName" @update:model-value="pickTheme" />
<p v-else-if="themesLoading" class="loading">主题列表加载中…</p>
<p v-else class="loading">主题列表为空 —— 请检查后端 /api/theme/list 是否可用。</p>
</div>
<!-- ② 微调 + 实时预览 -->
<div v-else-if="step === 2" class="wizard-step step2">
<div class="left">
<FormItem label="预览内容源" :label-width="90">
<Select v-model="sourceSiteCode" style="width: 260px">
<Option v-for="s in sourceSites" :key="s.id" :value="s.path || s.code">{{ s.name }}({{ s.path || s.code }})</Option>
</Select>
</FormItem>
<ThemeParamForm :theme="selectedTheme" v-model="themeParams" />
</div>
<div class="right">
<ThemePreviewFrame
v-if="canPreview"
:site-path="sourceSitePath"
:theme="themeName"
:key="previewTick"
:theme-params="previewParams"
/>
<div v-else class="no-preview">
暂无启用站点可作为内容源 —— 请先在站点管理创建一个启用站点,或初始化演示数据。
</div>
</div>
</div>
<!-- ③ 站点信息 + 确认 -->
<div v-else class="wizard-step step3">
<p class="step-tip">
站点将使用皮肤包 <b>{{ selectedTheme?.title }}</b>({{ themeName }}),并携带上述微调参数。
</p>
<Form ref="formRef" :model="form" :rules="rules" :label-width="110" class="info-form">
<FormItem label="站点编码" prop="code">
<Input v-model="form.code" placeholder="URL 路径用,如 demo" style="width: 320px" />
</FormItem>
<FormItem label="站点名称" prop="name">
<Input v-model="form.name" :maxlength="128" placeholder="如 XX学校门户网站" style="width: 320px" />
</FormItem>
<FormItem label="门户地址预览">
<span class="portal-preview-url">{{ previewUrl || "输入编码后自动生成" }}</span>
<div class="pbu-tip">子站地址 = 上级地址 / 编码;创建后经站点管理「门户路径」列直达。</div>
</FormItem>
<FormItem label="上级站点">
<Select v-model="form.parentSiteId" :disabled="!!props.lockParentId" placeholder="空=顶层校站点;选主站则为院系子站" style="width: 320px">
<Option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.name }}({{ s.code }})</Option>
</Select>
<div v-if="props.lockParentId" class="pbu-tip">校级角色仅可在本校主站下创建院系子站,学校信息自动继承主站。</div>
</FormItem>
<FormItem v-if="!props.lockParentId" label="学校ID">
<InputNumber v-model="form.schId" :min="1" placeholder="UserBase sys_group.id" style="width: 200px" />
<!-- B-8:顶层站 schId 必填(校级角色按此匹配管理权限);院系子站留空自动继承主站 -->
<div class="pbu-tip">
顶层站点必填(否则建成即校级角色不可管理);选了上级站点的院系子站可留空,自动继承主站。
</div>
</FormItem>
<FormItem v-if="!props.lockParentId" label="学校名称">
<Input v-model="form.schName" :maxlength="128" style="width: 320px" />
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<!-- 站点配置结构化表单(docs/09 §3.1,替代原 JSON textarea) -->
<SiteConfigForm ref="configFormRef" v-model="form.configJson" class="cfg-form" />
</div>
</div>
<template #footer>
<Button v-if="step > 1" @click="prev">上一步</Button>
<Button v-if="step < 3" type="primary" :disabled="!themes.length" @click="next">下一步</Button>
<Button v-else type="primary" :loading="saving" @click="createSite">创建站点</Button>
</template>
</Modal>
</template>
<style scoped lang="scss">
.wizard-step {
min-height: 300px;
}
.step-tip {
color: #888;
font-size: 13px;
margin: 0 0 12px;
}
.pbu-tip {
color: #888;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.loading {
color: #999;
font-size: 13px;
text-align: center;
padding: 60px 0;
}
.step2 {
display: flex;
gap: 20px;
.left {
width: 340px;
flex: none;
}
.right {
flex: 1;
min-width: 0;
}
}
.no-preview {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 13px;
border: 1px dashed #e5e6eb;
border-radius: 6px;
}
.info-form {
margin-top: 4px;
}
.portal-preview-url {
color: #2d8cf0;
font-family: Consolas, Menlo, monospace;
word-break: break-all;
}
.cfg-form {
margin-top: 12px;
padding-top: 4px;
border-top: 1px dashed #e5e6eb;
}
</style>
+286 -69
View File
@@ -1,16 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import WorkbenchHeader from "@/components/WorkbenchHeader.vue";
import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue"; import ThemePreviewFrame from "@/components/theme/ThemePreviewFrame.vue";
import ThemeParamForm from "@/components/theme/ThemeParamForm.vue";
import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms"; import { ThemeParamVo, ThemeVo, SiteVo } from "@/types/cms";
import { themeApi, siteApi, assetUrl } from "@/api/module/cms"; import { themeApi, siteApi, assetUrl } from "@/api/module/cms";
import { isValidParamValue, paramValuesOf, FormModelValue } from "@/utils/themeParamForm";
import { useCmsStore } from "@/store";
/** /**
* 皮肤包管理(W1,方案 §5.2/§5.4):内置只读(仅预览)、外部可管(上传/派生/编辑/删除)。 * 皮肤包管理(W1 + 任务二/四,2026-09-08):平台级全屏页(/theme-manage,超管专属,
* 入口:站点管理按钮(平台模块表注册前的兜底,R3)+ 平台菜单(modId=7)。 * 工作台按钮进入、不再挂左侧菜单)。内置包内容只读但显示名/描述可改(.meta 覆盖层)、
* 外部包可管(上传/派生/编辑/删除)。标识名(name,即主题路径/URL 段)仅创建时可定,创建后不可改。
* 工作项 T:参数值改挂皮肤包(themes/.meta/{name}.json → paramValues),编辑弹窗内嵌参数微调,
* 内置包与外部包同权(值只落覆盖层,不碰包内 theme.json);卡片显示「已微调 M 项」。
*/ */
const router = useRouter();
const store = useCmsStore();
const themes = ref<ThemeVo[]>([]); const themes = ref<ThemeVo[]>([]);
const loading = ref(false); const loading = ref(false);
const previewVisible = ref(false); const previewVisible = ref(false);
@@ -18,13 +29,36 @@ const previewTheme = ref<ThemeVo | null>(null);
const previewSitePath = ref(""); const previewSitePath = ref("");
const contentSources = ref<SiteVo[]>([]); const contentSources = ref<SiteVo[]>([]);
const deriveVisible = ref(false); // 上传表单(任务四:zip + 显示名称/描述)
const deriveForm = ref({ source: "default", name: "" }); const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFile = ref<File | null>(null);
const uploadForm = ref({ title: "", description: "" });
// 添加皮肤包表单(任务四:派生升级——源包 + 标识名(主题路径) + 显示名称/描述一步建成)
const addVisible = ref(false);
const adding = ref(false);
const addForm = ref({ source: "default", name: "", title: "", description: "" });
const metaVisible = ref(false); const metaVisible = ref(false);
const metaTheme = ref<ThemeVo | null>(null); const metaTheme = ref<ThemeVo | null>(null);
const metaTitle = ref(""); const metaTitle = ref("");
const metaDescription = ref("");
const metaParams = ref<ThemeParamVo[]>([]); const metaParams = ref<ThemeParamVo[]>([]);
/** 包级参数值模型(paramValuesOf 回显);paramTouched 决定保存时是否携带 paramValues */
const metaParamModel = ref<FormModelValue>({});
const paramTouched = ref(false);
// 非超管深链兜底(引导期守卫放行,user 装载后弹回工作台)
watch(
() => store.user,
u => {
if (u && !store.isSuperAdmin) {
router.replace("/workbench");
}
},
{ immediate: true }
);
async function run() { async function run() {
loading.value = true; loading.value = true;
@@ -37,9 +71,8 @@ async function run() {
onMounted(run); onMounted(run);
/** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览) */ /** 预览内容源站点(W1:与向导同语义,取启用站点按门户 path 预览);亦借给参数微调的 image 上传归属 */
async function openPreview(theme: ThemeVo) { async function ensureContentSources(): Promise<SiteVo[]> {
previewTheme.value = theme;
if (!contentSources.value.length) { if (!contentSources.value.length) {
try { try {
contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1); contentSources.value = ((await siteApi.list().send()) || []).filter(s => s.status === 1);
@@ -47,7 +80,16 @@ async function openPreview(theme: ThemeVo) {
contentSources.value = []; contentSources.value = [];
} }
} }
const first = contentSources.value[0]; return contentSources.value;
}
/** image 型参数上传需归属站点(后端 siteId 必填);无可用站点时控件退化为仅手填路径 */
const metaSiteId = computed(() => contentSources.value[0]?.id);
async function openPreview(theme: ThemeVo) {
previewTheme.value = theme;
const sources = await ensureContentSources();
const first = sources[0];
previewSitePath.value = first ? first.path || first.code : ""; previewSitePath.value = first ? first.path || first.code : "";
previewVisible.value = true; previewVisible.value = true;
} }
@@ -56,55 +98,126 @@ function onContentSourceChange(path: string) {
previewSitePath.value = path; previewSitePath.value = path;
} }
function beforeUpload(file: File) { /* ===== 上传表单(任务四):选 zip + 命名,提交走手动上传通道 ===== */
themeApi function openUpload() {
.upload(file) uploadFile.value = null;
.send() uploadForm.value = { title: "", description: "" };
.then(() => { uploadVisible.value = true;
Message.success("上传成功,皮肤包已注册(免重启)");
run();
})
.catch(() => {
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
});
return false; // 阻断 Upload 组件自动上传,改走手动
} }
function openDerive() { function onPickUploadFile(file: File) {
deriveForm.value = { source: themes.value[0]?.name || "default", name: "" }; uploadFile.value = file;
deriveVisible.value = true; return false; // 阻断 Upload 自动上传,统一由「上传」按钮提交
} }
async function doDerive() { async function doUpload() {
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(deriveForm.value.name)) { if (!uploadFile.value) {
Message.warning("新包名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾"); Message.warning("请先选择皮肤包 zip 文件");
return; return;
} }
await themeApi.derive(deriveForm.value.source, deriveForm.value.name).send(); uploading.value = true;
Message.success("派生成功,新皮肤包可编辑可删除"); try {
deriveVisible.value = false; await themeApi
.upload(uploadFile.value, {
title: uploadForm.value.title || undefined,
description: uploadForm.value.description,
})
.send();
Message.success("上传成功,皮肤包已注册(免重启)");
uploadVisible.value = false;
run(); run();
} catch {
// 错误详情由 alova 拦截器统一弹出(校验报告逐条展示)
} finally {
uploading.value = false;
}
} }
function openMeta(theme: ThemeVo) { /* ===== 添加皮肤包(任务四):从现有包复制 + 命名,一步建成 ===== */
function openAdd() {
addForm.value = { source: themes.value[0]?.name || "default", name: "", title: "", description: "" };
addVisible.value = true;
}
async function doAdd() {
if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(addForm.value.name)) {
Message.warning("标识名仅限小写字母/数字/短横线,且以字母开头、数字或字母结尾");
return;
}
adding.value = true;
try {
await themeApi
.derive(addForm.value.source, addForm.value.name, {
title: addForm.value.title || undefined,
description: addForm.value.description,
})
.send();
Message.success("添加成功,新皮肤包可编辑可删除");
addVisible.value = false;
run();
} finally {
adding.value = false;
}
}
/* ===== 编辑元数据(任务四:内置包亦可改显示名/描述,走覆盖层;工作项 T:包级参数值同区编辑) ===== */
async function openMeta(theme: ThemeVo) {
metaTheme.value = theme; metaTheme.value = theme;
metaTitle.value = theme.title; metaTitle.value = theme.title;
metaDescription.value = theme.description || "";
metaParams.value = (theme.params || []).map(p => ({ ...p })); metaParams.value = (theme.params || []).map(p => ({ ...p }));
metaParamModel.value = paramValuesOf(theme);
paramTouched.value = false;
metaVisible.value = true; metaVisible.value = true;
if ((theme.params || []).some(p => p.type === "image")) await ensureContentSources();
}
function onParamModelChange(v: FormModelValue) {
metaParamModel.value = v;
paramTouched.value = true;
}
/** 待提交的包级参数值:只带「合法且偏离包内默认」的 key(= 卡片「已微调」口径,与后端整体替换语义一致) */
function currentParamValues(): Record<string, string> {
const result: Record<string, string> = {};
(metaTheme.value?.params || []).forEach(p => {
const v = metaParamModel.value[p.key];
if (isValidParamValue(p, v) && v !== (p.defaultValue ?? "")) result[p.key] = v;
});
return result;
} }
async function doSaveMeta() { async function doSaveMeta() {
await themeApi const builtin = metaTheme.value?.source === "builtin";
.updateMeta(metaTheme.value!.name, { const payload = {
title: metaTitle.value, title: metaTitle.value,
params: metaParams.value, description: metaDescription.value,
}) // 内置包参数 schema 不可改(后端强校验),不传即不改动
.send(); params: builtin ? undefined : metaParams.value,
} as { title: string; description: string; params?: unknown[]; paramValues?: Record<string, string> };
// 未触碰参数区则不带 paramValues 键(后端 null=不改),避免改名顺带清空微调
if (paramTouched.value) payload.paramValues = currentParamValues();
await themeApi.updateMeta(metaTheme.value!.name, payload).send();
Message.success("已保存,引用该皮肤的站点门户与预览立即生效"); Message.success("已保存,引用该皮肤的站点门户与预览立即生效");
metaVisible.value = false; metaVisible.value = false;
run(); run();
} }
/** 内置包恢复默认(清空 .meta 覆盖层:改名 + 描述 + 包级参数微调一并回到包内声明) */
async function doResetBuiltinMeta() {
MyConfirm({
title: "恢复默认名称",
msg: `确定恢复「${metaTheme.value?.name}」的默认显示名称与描述吗?`,
info: "将清除改名覆盖层,恢复包内声明的原始名称;包级参数微调一并清空",
async onOk() {
await themeApi.updateMeta(metaTheme.value!.name, { title: "", description: "" }).send();
Message.success("已恢复默认名称");
metaVisible.value = false;
run();
},
});
}
function askRemove(theme: ThemeVo) { function askRemove(theme: ThemeVo) {
if (theme.source === "builtin") { if (theme.source === "builtin") {
Message.warning("内置包不可删除"); Message.warning("内置包不可删除");
@@ -127,17 +240,34 @@ function askRemove(theme: ThemeVo) {
} }
const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length); const builtinCount = computed(() => themes.value.filter(t => t.source === "builtin").length);
/** 卡片「已微调 M 项」口径:包级 paramValues 里合法且偏离 theme.json 默认值的 key 数 */
function tweakedCount(t: ThemeVo): number {
const stored = t.paramValues || {};
return (t.params || []).filter(p => {
const v = stored[p.key];
return isValidParamValue(p, v) && v !== (p.defaultValue ?? "");
}).length;
}
function tweakSummary(t: ThemeVo): string {
const m = tweakedCount(t);
return m ? `已微调 ${m} 项` : "未微调";
}
</script> </script>
<template> <template>
<div class="theme-manage"> <div class="theme-page">
<WorkbenchHeader title="云海CMS · 皮肤包管理" back-to="/workbench" />
<div class="theme-body">
<div class="toolbar"> <div class="toolbar">
<Upload :before-upload="beforeUpload" accept=".zip" :show-upload-list="false"> <Button type="primary" icon="ios-cloud-upload-outline" @click="openUpload">上传皮肤包</Button>
<Button type="primary" icon="ios-cloud-upload-outline">上传皮肤包(zip)</Button> <Button icon="ios-copy" @click="openAdd">添加皮肤包</Button>
</Upload>
<Button icon="ios-copy" @click="openDerive">从现有包派生</Button>
<Button icon="ios-refresh" @click="run">刷新</Button> <Button icon="ios-refresh" @click="run">刷新</Button>
<span class="summary">内置 {{ builtinCount }} 个 · 外部 {{ themes.length - builtinCount }} 个(内置只读,外部可管)</span> <span class="summary">
内置 {{ builtinCount }} 个 · 外部 {{ themes.length - builtinCount }} 个(内置可改名,外部可管;标识名即主题路径,创建后不可改)
</span>
</div> </div>
<Spin v-if="loading" size="large" style="display: block; margin: 60px auto" /> <Spin v-if="loading" size="large" style="display: block; margin: 60px auto" />
@@ -152,13 +282,14 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
<div class="meta"> <div class="meta">
<p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p> <p class="title">{{ t.title }}<span class="name">({{ t.name }})</span></p>
<p class="desc">{{ t.description || "—" }}</p> <p class="desc">{{ t.description || "—" }}</p>
<p class="stats">参数 {{ (t.params || []).length }} 个 · 被用 {{ t.inUseCount || 0 }} 站点</p> <p class="stats">参数 {{ (t.params || []).length }} 个 · {{ tweakSummary(t) }} · 被用 {{ t.inUseCount || 0 }} 站点</p>
</div> </div>
<div class="actions"> <div class="actions">
<Button size="small" @click="openPreview(t)">预览</Button> <Button size="small" @click="openPreview(t)">预览</Button>
<template v-if="t.source === 'external'">
<Tooltip <Tooltip
:content="(t.inUseCount || 0) > 0 :content="t.source === 'builtin'
? '内置包内容只读,可改显示名称与描述、微调包级参数值'
: (t.inUseCount || 0) > 0
? `被站点引用:${(t.usedBySiteNames || []).join('、')}` ? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
: '外部包可编辑参数 schema'" : '外部包可编辑参数 schema'"
placement="top" placement="top"
@@ -166,6 +297,7 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
> >
<Button size="small" @click="openMeta(t)">编辑</Button> <Button size="small" @click="openMeta(t)">编辑</Button>
</Tooltip> </Tooltip>
<template v-if="t.source === 'external'">
<Tooltip <Tooltip
:content="(t.inUseCount || 0) > 0 :content="(t.inUseCount || 0) > 0
? `被站点引用:${(t.usedBySiteNames || []).join('、')}` ? `被站点引用:${(t.usedBySiteNames || []).join('、')}`
@@ -187,9 +319,61 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 预览弹窗(复用 ThemePreviewFrame,内容源下拉同向导) --> <!-- 上传表单弹窗(任务四):zip + 显示名称/描述 -->
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" width="860px"> <Modal v-model="uploadVisible" title="上传皮肤包" :mask-closable="false" width="520px">
<Form :label-width="100">
<FormItem label="皮肤包 zip" required>
<Upload :before-upload="onPickUploadFile" accept=".zip" :show-upload-list="false">
<Button icon="ios-folder-open">选择文件</Button>
</Upload>
<p class="form-tip">
{{ uploadFile ? `已选择:${uploadFile.name}` : "尚未选择文件(结构:zip 顶层唯一文件夹,名字与 theme.json 的 name 一致)" }}
</p>
</FormItem>
<FormItem label="显示名称">
<Input v-model="uploadForm.title" :maxlength="64" placeholder="选填,留空沿用包内 theme.json 的标题" />
</FormItem>
<FormItem label="描述">
<Input v-model="uploadForm.description" :maxlength="128" placeholder="选填" />
</FormItem>
</Form>
<template #footer>
<Button @click="uploadVisible = false">取消</Button>
<Button type="primary" :loading="uploading" :disabled="!uploadFile" @click="doUpload">上传</Button>
</template>
</Modal>
<!-- 添加皮肤包(任务四):源包 + 标识名(主题路径) + 命名,一步建成 -->
<Modal v-model="addVisible" title="添加皮肤包" :mask-closable="false" width="520px">
<Form :label-width="100">
<FormItem label="复制自">
<Select v-model="addForm.source" style="width: 100%">
<Option v-for="t in themes" :key="t.name" :value="t.name">{{ t.title }}({{ t.name }})</Option>
</Select>
<p class="form-tip">新包完整复制源包的模板与参数,可独立编辑、删除。</p>
</FormItem>
<FormItem label="标识名" required>
<Input v-model="addForm.name" placeholder="如 mint-green(小写字母/数字/短横线)" />
<p class="form-tip">即主题路径(URL 中的主题段),创建后不可修改。</p>
</FormItem>
<FormItem label="显示名称">
<Input v-model="addForm.title" :maxlength="64" placeholder="选填,如 薄荷绿;留空沿用源包名称" />
</FormItem>
<FormItem label="描述">
<Input v-model="addForm.description" :maxlength="128" placeholder="选填" />
</FormItem>
</Form>
<template #footer>
<Button @click="addVisible = false">取消</Button>
<Button type="primary" :loading="adding" @click="doAdd">添加</Button>
</template>
</Modal>
<!-- 预览弹窗(全屏展示,复用 ThemePreviewFrame,内容源下拉同向导) -->
<Modal v-model="previewVisible" :title="`预览皮肤:${previewTheme?.title || ''}`" fullscreen footer-hide>
<div class="preview-full">
<div class="preview-source"> <div class="preview-source">
<label>内容源站点</label> <label>内容源站点</label>
<Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange"> <Select :model-value="previewSitePath" style="width: 280px" @on-change="onContentSourceChange">
@@ -198,39 +382,47 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</Option> </Option>
</Select> </Select>
</div> </div>
<ThemePreviewFrame :site-path="previewSitePath" :theme="previewTheme?.name" :theme-params="{}" /> <ThemePreviewFrame
fill
:site-path="previewSitePath"
:theme="previewTheme?.name"
:theme-params="previewTheme?.paramValues || {}"
/>
</div>
</Modal> </Modal>
<!-- 派生弹窗 --> <!-- 编辑弹窗(任务四:内置包仅显示名/描述;外部包另含参数 schema。工作项 T:包级参数值两态同权编辑) -->
<Modal v-model="deriveVisible" title="从现有包派生新皮肤" :mask-closable="false" width="480px">
<Form :label-width="100">
<FormItem label="派生源">
<Select v-model="deriveForm.source" style="width: 280px">
<Option v-for="t in themes" :key="t.name" :value="t.name">{{ t.title }}({{ t.name }})</Option>
</Select>
</FormItem>
<FormItem label="新包名" required>
<Input v-model="deriveForm.name" placeholder="如 my-skin(小写字母/数字/短横线)" style="width: 280px" />
</FormItem>
</Form>
<template #footer>
<Button @click="deriveVisible = false">取消</Button>
<Button type="primary" @click="doDerive">派生</Button>
</template>
</Modal>
<!-- 编辑弹窗(外部包:title + 参数 schema 默认值) -->
<Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px"> <Modal v-model="metaVisible" :title="`编辑皮肤:${metaTheme?.name || ''}`" :mask-closable="false" width="560px">
<Alert v-if="metaTheme?.source === 'builtin'" type="info" show-icon style="margin-bottom: 12px">
内置包内容(模板/资产)不可修改,仅显示名称与描述生效;标识名({{ metaTheme?.name }})不可改。
下方的参数微调只写包级覆盖层,不改包内 theme.json。
</Alert>
<Form :label-width="110"> <Form :label-width="110">
<FormItem label="标题"> <FormItem label="显示名称">
<Input v-model="metaTitle" :maxlength="64" /> <Input v-model="metaTitle" :maxlength="64" />
</FormItem> </FormItem>
<FormItem label="描述">
<Input v-model="metaDescription" :maxlength="128" />
</FormItem>
<template v-if="metaTheme?.source !== 'builtin'">
<FormItem v-for="p in metaParams" :key="p.key" :label="p.label"> <FormItem v-for="p in metaParams" :key="p.key" :label="p.label">
<ColorPicker v-if="p.type === 'color'" v-model="p.defaultValue" /> <ColorPicker v-if="p.type === 'color'" v-model="p.defaultValue" />
<Input v-else v-model="p.defaultValue" :disabled="p.type === 'select'" /> <Input v-else v-model="p.defaultValue" :disabled="p.type === 'select'" />
</FormItem> </FormItem>
</template>
</Form> </Form>
<div class="meta-params">
<ThemeParamForm
:theme="metaTheme || undefined"
:model-value="metaParamModel"
:site-id="metaSiteId"
@update:model-value="onParamModelChange"
/>
</div>
<template #footer> <template #footer>
<Button v-if="metaTheme?.source === 'builtin'" style="float: left" @click="doResetBuiltinMeta">
恢复默认名称
</Button>
<Button @click="metaVisible = false">取消</Button> <Button @click="metaVisible = false">取消</Button>
<Button type="primary" @click="doSaveMeta">保存</Button> <Button type="primary" @click="doSaveMeta">保存</Button>
</template> </template>
@@ -239,9 +431,23 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.theme-manage { .theme-page {
height: 100%;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.theme-body {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px; padding: 0 24px 24px;
} }
/* 全屏预览:弹窗 body 高度确定(top:51px/bottom:0),内容源行占自然高、预览框吃满余量 */
.preview-full {
height: 100%;
display: flex;
flex-direction: column;
}
.preview-source { .preview-source {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -318,4 +524,15 @@ const builtinCount = computed(() => themes.value.filter(t => t.source === "built
gap: 8px; gap: 8px;
} }
} }
.meta-params {
margin-top: 4px;
padding-top: 12px;
border-top: 1px dashed #e9edf5;
}
.form-tip {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
</style> </style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed } from "vue";
import { assetUrl } from "@/api/module/cms";
import { portalUrlOf } from "@/utils/sitePath";
import { SiteVo, ThemeVo } from "@/types/cms";
/**
* 院系子站卡(docs/22):站点工作台网格单元。
* 信息按「身份 → 规模 → 皮肤 → 地址 → 动作」纵向分层,不再与表格争列宽。
* 当前站 = 蓝描边 + 「当前」绿徽章;停用 = 降透明 + 灰图标 + 「进入管理」禁用。
*/
const props = defineProps<{
site: SiteVo;
isCurrent: boolean;
theme?: ThemeVo;
}>();
const emit = defineEmits<{
(e: "enter", site: SiteVo): void;
(e: "setting", site: SiteVo): void;
(e: "portal", site: SiteVo): void;
}>();
const disabled = computed(() => props.site.status !== 1);
/** 当前站无需「进入管理」(已在站内),与停用同视为不可达 */
const enterDisabled = computed(() => disabled.value || props.isCurrent);
/**
* 规模统计:SiteVo 暂未提供聚合字段(docs/22 Q1),故当前渲染为空、该行自动隐藏;
* 后端在 /api/site/list 补齐 categoryCount / articleCount / updateTime 后自动出现,模板不动。
*/
const stats = computed(() => {
const s = props.site as any;
const list: { num: string | number; lbl: string }[] = [];
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
if (s.updateTime) list.push({ num: String(s.updateTime).slice(5, 10), lbl: "更新" });
return list;
});
/** 皮肤缩略图(走 assetUrl 补资源前缀,与表格视图同源) */
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
</script>
<template>
<div class="site-card" :class="{ 'is-cur': isCurrent, disabled }">
<div class="sc-head">
<span class="sc-ico" :class="disabled ? 'off' : 'sub'">
<Icon type="ios-cog" />
</span>
<div class="sc-id">
<div class="sc-name">
<h4 :title="site.name">{{ site.name }}</h4>
<span class="sc-badge sub">院系子站</span>
<span v-if="isCurrent" class="sc-badge cur">当前</span>
<span v-else-if="disabled" class="sc-badge off">停用</span>
</div>
<p class="sc-code">{{ site.path || site.code }}</p>
</div>
</div>
<div v-if="stats.length" class="sc-meta">
<span v-for="st in stats" :key="st.lbl">{{ st.lbl }} <b>{{ st.num }}</b></span>
</div>
<div class="sc-theme">
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
<span class="tname" :title="themeTitle">
{{ themeTitle }}
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
</span>
</div>
<p class="sc-domain">
<Icon type="ios-globe-outline" />
<code :title="portalUrlOf(site)">{{ portalUrlOf(site) }}</code>
</p>
<div class="sc-actions">
<Button type="primary" size="small" :disabled="enterDisabled" @click="emit('enter', site)">
进入管理
</Button>
<Button size="small" @click="emit('setting', site)">站点设置</Button>
<Button size="small" @click="emit('portal', site)">门户</Button>
</div>
</div>
</template>
<style scoped lang="scss">
/* 与超管 SchoolWorkbench 的卡片语法同构:白底 8px 圆角 + 1px 边 + hover 抬升 2px */
.site-card {
position: relative;
overflow: hidden;
background: #fff;
border: 1px solid #e8eaec;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
&:hover {
box-shadow: 0 4px 16px rgba(31, 42, 58, 0.12);
transform: translateY(-2px);
border-color: #c9d6fb;
}
/* 当前操作站点:4px 左色带 + 蓝描边 + 淡蓝光环。
与 SiteMainCard 同一套语义 —— 全页同时只有一张卡带「选中」,不再出现第二处蓝色标识。 */
&.is-cur {
border-color: #9db4f7;
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
}
&.is-cur::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #577afb;
}
&.disabled {
opacity: 0.62;
background: #fafbfc;
&:hover {
transform: none;
box-shadow: 0 1px 2px rgba(31, 42, 58, 0.06);
}
}
}
.sc-head {
display: flex;
align-items: flex-start;
gap: 10px;
}
/* 身份色:主站 #577afb / 子站 #ff9900(与 SiteSwitcher 定稿身份色一致) */
.sc-ico {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #fff;
background: #577afb;
&.sub {
background: #ff9900;
}
&.off {
background: #c0c4cc;
}
}
.sc-id {
min-width: 0;
flex: 1;
}
.sc-name {
display: flex;
align-items: center;
gap: 6px;
h4 {
margin: 0;
font-size: 15px;
color: #17233d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sc-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 16px;
padding: 0 5px;
border-radius: 3px;
color: #fff;
&.sub {
background: #ff9900;
}
&.cur {
background: #2ba471;
}
&.off {
background: #c0c4cc;
}
}
.sc-code {
margin: 3px 0 0;
font-size: 12px;
color: #808695;
font-family: Consolas, Monaco, monospace;
}
.sc-meta {
display: flex;
gap: 14px;
margin: 11px 0 0;
font-size: 12px;
color: #515a6e;
b {
color: #17233d;
}
}
.sc-theme {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0;
padding-top: 10px;
border-top: 1px dashed #eceff3;
font-size: 12px;
color: #656667;
.thumb {
width: 52px;
height: 32px;
border-radius: 4px;
border: 1px solid #e8eaec;
flex-shrink: 0;
background: #f0f2f5 center/cover no-repeat;
}
.tname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tspec {
color: #a0a6b0;
font-family: Consolas, Monaco, monospace;
font-size: 11px;
}
}
.sc-domain {
margin: 8px 0 0;
font-size: 12px;
color: #808695;
display: flex;
align-items: center;
gap: 5px;
code {
font-family: Consolas, Monaco, monospace;
color: #656667;
background: #f5f7fa;
border-radius: 3px;
padding: 1px 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sc-actions {
display: flex;
gap: 8px;
margin: 14px 0 0;
/* 三个动作等宽铺满(iview Button 是子组件根节点,scoped 属性会下发到根 DOM) */
.ivu-btn {
flex: 1;
padding-left: 0;
padding-right: 0;
}
}
</style>
+101 -74
View File
@@ -1,31 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { nextTick, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { import {
SITE_CONFIG_FIELD_DEFS, SITE_CONFIG_FIELD_DEFS,
SITE_CONFIG_GROUPS, SITE_CONFIG_GROUPS,
SiteConfigExtraRow,
SiteConfigFieldDef, SiteConfigFieldDef,
SiteConfigFormModel, SiteConfigFormModel,
extraRowError, SiteConfigGroup,
firstExtraError,
parseSiteConfig, parseSiteConfig,
serializeSiteConfig, serializeSiteConfig,
validateFieldValue, validateFieldValue,
} from "@/utils/siteConfigForm"; } from "@/utils/siteConfigForm";
import ImageAssetField from "@/components/ImageAssetField.vue";
import FooterPreview from "@/components/FooterPreview.vue";
/** /**
* 站点配置结构化表单(docs/09 §3.1):v-model 直接绑定 configJson 字符串。 * 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域;docs/20 F6 扩展参数区退役):
* 内部 parse/serialize 双向映射;已知键分组表单 + 扩展参数动态行; * v-model 绑定整页共享的 configJson 字符串(单一事实源)。
* 保存前由父级调用 validate()(含扩展行兜底校验)。 * props.groups 声明本分区渲染的分组。模型仍完整解析全部键(fields/preserved 双通道),
* 仅渲染限定分组——序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
* 保存前由父级调用 validate()。
*/ */
const props = defineProps<{ modelValue: string }>(); const props = defineProps<{
modelValue: string;
/** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */
groups?: SiteConfigGroup[];
/** 图片类字段上传归属站点(附件场景引用) */
siteId?: number;
}>();
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>(); const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
/** 本实例渲染的分组集合(空=全部) */
const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.name)))();
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
const formRef = ref(); const formRef = ref();
const formModel = reactive<{ fields: Record<string, string>; extras: SiteConfigExtraRow[] }>({ /** 组件根元素(校验失败时定位滚动用) */
const rootEl = ref<HTMLElement | null>(null);
const formModel = reactive<{ fields: Record<string, string> }>({
fields: {}, fields: {},
extras: [],
}); });
let preserved: Record<string, unknown> = {}; let preserved: Record<string, unknown> = {};
let lastEmitted: string | null = null; let lastEmitted: string | null = null;
@@ -34,7 +46,6 @@ function parseIn(v: string) {
const m: SiteConfigFormModel = parseSiteConfig(v); const m: SiteConfigFormModel = parseSiteConfig(v);
Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]); Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]);
Object.assign(formModel.fields, m.fields); Object.assign(formModel.fields, m.fields);
formModel.extras.splice(0, formModel.extras.length, ...m.extras.map(r => ({ ...r })));
preserved = m.preserved; preserved = m.preserved;
} }
@@ -48,36 +59,65 @@ watch(
); );
function emitUp() { function emitUp() {
lastEmitted = serializeSiteConfig({ const next = serializeSiteConfig({
fields: { ...formModel.fields }, fields: { ...formModel.fields },
extras: formModel.extras.map(r => ({ ...r })),
preserved, preserved,
}); });
emit("update:modelValue", lastEmitted); // docs/20 返工 #2 no-op 守卫:本次序列化结果与当前传入 modelValue 经同一解析/序列化管线归一后
// 语义等价则不回写——挂载规范化、编辑后撤销往返不再触发父级假脏(保存路径读 ownFields() 取
// 实时表单模型,此处跳发不会造成取值滞后)。
if (next === serializeSiteConfig(parseSiteConfig(props.modelValue))) {
return;
}
lastEmitted = next;
emit("update:modelValue", next);
} }
function addExtra() { /**
formModel.extras.push({ key: "", value: "" }); * 保存前校验(docs/20 返工 #4,契约由 boolean 改为 string | null):
* 返回 null = 通过;失败返回首个错误的原因文案(「字段名:原因」)。
* 本分区无必填字段(空值恒通过),只拦「填了但格式非法」的值;iView 内联红框 + 字段下方
* 错误文案由 Form.validate 标注,此处负责把首个出错字段滚动到可视区。
*/
async function validate(): Promise<string | null> {
const form = formRef.value;
if (!form) {
// REWORK-DECISION: Form 未挂载视为无可校验内容(全字段可选,空值恒通过),不拦保存
return null;
}
const ok = await form.validate();
if (ok) {
return null;
}
await nextTick();
const firstErrorItem = rootEl.value?.querySelector<HTMLElement>(".ivu-form-item-error") || null;
firstErrorItem?.scrollIntoView?.({ block: "center", behavior: "smooth" });
const label = firstErrorItem?.querySelector(".ivu-form-item-label")?.textContent?.trim() || "";
const tip = firstErrorItem?.querySelector(".ivu-form-item-error-tip")?.textContent?.trim() || "";
if (label && tip) {
return `${label}:${tip}`;
}
return tip || "存在填写不合法的字段,请检查表单中标红项";
} }
function removeExtra(i: number) { /**
formModel.extras.splice(i, 1); * F1(docs/20)Tab 独立保存:仅导出本实例渲染分组的字段值(空值剔除)。
emitUp(); * 父级把返回值合并进服务端事实源 configJson 后提交——其他 Tab 的未保存修改不会被连带提交。
*/
function ownFields(): Record<string, string> {
const out: Record<string, string> = {};
visibleGroups.forEach(g => {
defsOf(g.name).forEach(d => {
const v = (formModel.fields[d.key] || "").trim();
if (v) {
out[d.key] = v;
}
});
});
return out;
} }
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底;通过返回 true */ defineExpose({ validate, ownFields });
async function validate(): Promise<boolean> {
const valid = await formRef.value?.validate();
if (!valid) return false;
const extraMsg = firstExtraError(formModel.extras);
if (extraMsg) {
Message.error(`扩展参数有误:${extraMsg}`);
return false;
}
return true;
}
defineExpose({ validate });
function rulesFor(def: SiteConfigFieldDef) { function rulesFor(def: SiteConfigFieldDef) {
return [ return [
@@ -93,13 +133,12 @@ function rulesFor(def: SiteConfigFieldDef) {
function defsOf(group: string): SiteConfigFieldDef[] { function defsOf(group: string): SiteConfigFieldDef[] {
return SITE_CONFIG_FIELD_DEFS.filter(d => d.group === group); return SITE_CONFIG_FIELD_DEFS.filter(d => d.group === group);
} }</script>
</script>
<template> <template>
<div class="site-config-form"> <div ref="rootEl" class="site-config-form">
<Form ref="formRef" :model="formModel" :label-width="130"> <Form ref="formRef" :model="formModel" :label-width="130">
<template v-for="g in SITE_CONFIG_GROUPS" :key="g.name"> <template v-for="g in visibleGroups" :key="g.name">
<div class="group-title">{{ g.title }}</div> <div class="group-title">{{ g.title }}</div>
<FormItem <FormItem
v-for="def in defsOf(g.name)" v-for="def in defsOf(g.name)"
@@ -108,8 +147,16 @@ function defsOf(group: string): SiteConfigFieldDef[] {
:prop="`fields.${def.key}`" :prop="`fields.${def.key}`"
:rules="rulesFor(def)" :rules="rulesFor(def)"
> >
<ImageAssetField
v-if="def.kind === 'asset'"
v-model="formModel.fields[def.key]"
:site-id="siteId"
:placeholder="def.placeholder"
:aspect="def.aspect"
@update:model-value="emitUp"
/>
<Input <Input
v-if="!def.multiline" v-else-if="!def.multiline"
v-model="formModel.fields[def.key]" v-model="formModel.fields[def.key]"
:placeholder="def.placeholder" :placeholder="def.placeholder"
@on-change="emitUp" @on-change="emitUp"
@@ -124,21 +171,23 @@ function defsOf(group: string): SiteConfigFieldDef[] {
/> />
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p> <p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
</FormItem> </FormItem>
<!-- F5(docs/20):页脚分组底部固定结构示意图(字段↔视觉位置一一对应,实时回显) -->
<FooterPreview
v-if="g.name === 'footer'"
:contact="formModel.fields.contact"
:footer-addresses="formModel.fields.footer_addresses"
:qr-wechat="formModel.fields.qr_wechat"
:qr-weibo="formModel.fields.qr_weibo"
:socials="[
{ label: '微信', value: formModel.fields.social_wechat },
{ label: '微博', value: formModel.fields.social_weibo },
{ label: '抖音', value: formModel.fields.social_douyin },
{ label: 'B站', value: formModel.fields.social_bilibili },
]"
:copyright="formModel.fields.copyright"
:beian="formModel.fields.beian"
/>
</template> </template>
<div class="group-title">扩展参数</div>
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row">
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" />
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" />
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" />
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error">
{{ extraRowError(formModel.extras, i) }}
</p>
</div>
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button>
<p class="cfg-tip">
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。
</p>
</Form> </Form>
</div> </div>
</template> </template>
@@ -163,27 +212,5 @@ function defsOf(group: string): SiteConfigFieldDef[] {
line-height: 1.6; line-height: 1.6;
margin-top: 4px; margin-top: 4px;
} }
.cfg-error {
color: #ed4014;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.extra-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
.extra-del {
color: #999;
&:hover {
color: #ed4014;
}
}
}
.extra-add {
margin: 4px 0 8px;
}
} }
</style> </style>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed } from "vue";
import { assetUrl } from "@/api/module/cms";
import { portalUrlOf } from "@/utils/sitePath";
import { SiteVo, ThemeVo } from "@/types/cms";
/**
* 学校主站宽卡(docs/22):站点工作台的层级锚点。
* 主站唯一、子站都挂在它下面 —— 横向宽卡独占一行(左侧 4px 主色带),
* 与子站网格在版式上分区;层级不靠「所属主站」列的文字表达(那是旧表格的做法)。
*/
const props = defineProps<{
site: SiteVo;
isCurrent: boolean;
theme?: ThemeVo;
/** 本校院系子站总数(由父级按全量行统计,搜索态下不随过滤变化) */
subCount: number;
}>();
const emit = defineEmits<{
(e: "enter", site: SiteVo): void;
(e: "setting", site: SiteVo): void;
(e: "portal", site: SiteVo): void;
}>();
const disabled = computed(() => props.site.status !== 1);
const enterDisabled = computed(() => disabled.value || props.isCurrent);
/** 统计:子站数本地可算(必显);栏目/文章同 SiteCard —— 后端补齐字段后自动出现 */
const stats = computed(() => {
const s = props.site as any;
const list: { num: string | number; lbl: string }[] = [];
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
list.push({ num: props.subCount, lbl: "子站" });
return list;
});
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
</script>
<template>
<div class="main-card" :class="{ 'is-cur': isCurrent, disabled }">
<div class="mc-body">
<div class="mc-left">
<span class="sc-ico" :class="disabled ? 'off' : ''">
<Icon type="ios-school" />
</span>
<div class="sc-id">
<div class="sc-name">
<h4 :title="site.name">{{ site.name }}</h4>
<span class="sc-badge main">主站</span>
<span v-if="isCurrent" class="sc-badge cur">当前</span>
<span v-else-if="disabled" class="sc-badge off">停用</span>
</div>
<p class="sc-code">{{ site.code }} · {{ portalUrlOf(site) }}</p>
<div class="sc-theme">
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
<span class="tname" :title="themeTitle">
{{ themeTitle }}
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
</span>
</div>
</div>
</div>
<div class="mc-stats">
<div class="st" v-for="st in stats" :key="st.lbl">
<div class="num">{{ st.num }}</div>
<div class="lbl">{{ st.lbl }}</div>
</div>
</div>
<div class="mc-actions">
<Button type="primary" :disabled="enterDisabled" @click="emit('enter', site)">进入管理</Button>
<Button @click="emit('setting', site)">站点设置</Button>
<Button @click="emit('portal', site)">门户预览</Button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
/* 主站宽卡:与子站卡共用同一套「当前站」语义 —— 4px 左色带 + 蓝描边 + 淡蓝光环,
全页同时只有一张卡带选中样式(原色带常驻表达「根节点」,会被误读成选中态)。
层级改由版式表达:宽卡独占一行 + 「主站」徽章 + 「N 子站」统计。
色带走 ::before 绝对定位而非 border-left —— 用边框会在切换态时把内容挤偏 3px。 */
.main-card {
position: relative;
overflow: hidden;
background: #fff;
border: 1px solid #e8eaec;
border-radius: 8px;
margin-bottom: 26px;
&.is-cur {
border-color: #9db4f7;
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
}
&.is-cur::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #577afb;
}
&.disabled {
opacity: 0.62;
background: #fafbfc;
}
}
.mc-body {
display: flex;
align-items: center;
gap: 20px;
padding: 18px 20px;
}
.mc-left {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
flex: 1;
}
.sc-ico {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 19px;
color: #fff;
background: #577afb;
&.off {
background: #c0c4cc;
}
}
.sc-id {
min-width: 0;
flex: 1;
}
.sc-name {
display: flex;
align-items: center;
gap: 6px;
h4 {
margin: 0;
font-size: 17px;
color: #17233d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sc-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 16px;
padding: 0 5px;
border-radius: 3px;
color: #fff;
&.main {
background: #577afb;
}
&.cur {
background: #2ba471;
}
&.off {
background: #c0c4cc;
}
}
.sc-code {
margin: 3px 0 0;
font-size: 12px;
color: #808695;
font-family: Consolas, Monaco, monospace;
}
.sc-theme {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 0;
font-size: 12px;
color: #656667;
.thumb {
width: 52px;
height: 32px;
border-radius: 4px;
border: 1px solid #e8eaec;
flex-shrink: 0;
background: #f0f2f5 center/cover no-repeat;
}
.tname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tspec {
color: #a0a6b0;
font-family: Consolas, Monaco, monospace;
font-size: 11px;
}
}
.mc-stats {
display: flex;
flex-shrink: 0;
.st {
padding: 0 18px;
border-left: 1px solid #eef0f3;
text-align: center;
&:first-child {
border-left: none;
}
}
.num {
font-size: 19px;
font-weight: bold;
color: #17233d;
line-height: 1.2;
}
.lbl {
font-size: 11px;
color: #a0a6b0;
margin-top: 2px;
}
}
.mc-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
</style>
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
/**
* 首页布局区块类型注册表(docs/16 §4.2):通用组件库的单一事实源。
* 新增一种区块组件 = 注册表加一项 + 门户写一个 fragment(common 或主题自有),
* 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。
* propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。
*/
import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MAX, styleLabel } from "./homeLayout";
export interface BlockPropField {
key: string;
label: string;
kind: "text" | "number" | "select" | "switch" | "categories" | "links"
| "stats" | "topics" | "badges" | "category" | "video";
options?: { value: string; label: string }[];
min?: number;
max?: number;
/** text 输入框字数上限(缺省 100) */
maxlength?: number;
placeholder?: string;
tip?: string;
/** 条件显示(返回 false 时隐藏且不校验) */
showIf?: (block: LayoutBlock) => boolean;
/** select 的可视化选项卡渲染(RadioCard + 缩略图,F2/R-40:形态选择不盲选) */
card?: boolean;
}
export interface BlockTypeDef {
type: string;
title: string;
icon: string;
description: string;
/** 添加区块时的初始属性 */
defaultProps: () => Partial<LayoutBlock>;
propFields: BlockPropField[];
}
export const BLOCK_TYPES: BlockTypeDef[] = [
{
type: "article_list",
title: "栏目文章区",
icon: "md-list",
description: "按栏目聚合文章列表,支持五种形态;多栏目可合并为一块",
defaultProps: () => ({ style: "card", size: 8, categoryIds: [] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "留空显示第一个栏目名" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Campus News(主题开双语后显示)" },
{
key: "categoryIds", label: "数据栏目", kind: "categories",
tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
},
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES, card: true, tip: "卡片缩略图为该形态的典型渲染示意,最终视觉以皮肤包实现为准" },
{
key: "size", label: "文章条数", kind: "number", min: 1, max: 20,
showIf: b => b.style !== "image_wall",
},
{
key: "background", label: "显示背景大图", kind: "switch",
showIf: b => b.style === "news_grid",
tip: "背景图取站点原「新闻卡背景图」配置(站点设置→基础展示可维护上传)",
},
],
},
{
type: "ticker",
title: "滚动速递",
icon: "md-megaphone",
description: "横向滚动条:全站置顶文章或指定栏目最新文章",
defaultProps: () => ({ source: "top", size: 6 }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「要闻速递」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Latest News(主题开双语后显示)" },
{
key: "source", label: "数据来源", kind: "select",
options: [
{ value: "top", label: "全站置顶文章" },
{ value: "category", label: "指定栏目最新" },
],
},
{
key: "categoryIds", label: "数据栏目", kind: "categories",
showIf: b => b.source === "category",
},
{ key: "size", label: "文章条数", kind: "number", min: 1, max: 20 },
],
},
{
type: "gallery",
title: "图片画廊",
icon: "md-images",
description: "线上展厅:轮播管理「线上展厅」展示位的图片(数量为全部展示)",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「线上展厅」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "默认 Online Exhibition Hall" },
{
key: "layout", label: "布局形态", kind: "select",
options: [
{ value: "grid", label: "规整网格" },
{ value: "masonry", label: "瀑布流(campus_wall)" },
],
tip: "瀑布流 = 不等高错落排布,hover 放大;适合校园风光多图",
},
],
},
{
type: "links",
title: "链接组",
icon: "md-link",
description: "友情链接分组(友情链接管理维护数据)",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「友情链接」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Friendly Links(主题开双语后显示)" },
{ key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" },
],
},
{
type: "quick_links",
title: "快捷通道",
icon: "md-flash",
description: "入口墙:自行填写 1~12 个跳转入口(名称 + 地址),不依赖栏目与友情链接数据",
defaultProps: () => ({ items: [{ name: "", url: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Quick Links(主题开双语后显示)" },
{
key: "items", label: "入口列表", kind: "links",
tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址 + 可选图标,最多 ${QUICK_LINK_MAX} 个;未选图标显示首字徽章`,
},
],
},
{
type: "notice_bar",
title: "公告条",
icon: "md-volume-up",
description: "通栏提示条:一条全站公告(放假/停网/招生等),可选跳转与醒目配色",
defaultProps: () => ({ text: "", level: "info" }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选(主题开双语后显示)" },
{
key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX,
placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`,
},
{ key: "url", label: "跳转地址", kind: "text", placeholder: "可选:http(s):// 或站内 / 路径" },
{
key: "level", label: "提示级别", kind: "select",
options: [
{ value: "info", label: "一般(主题色)" },
{ value: "warn", label: "醒目(警示红)" },
],
},
],
},
// ===== docs/20 D-1~D-9 新组件(骨架兜底渲染,所有主题可用) =====
{
type: "stats_band",
title: "数字计数带",
icon: "md-stats",
description: "在校生/教职工/占地/院士等大数字横排,滚动可见时自动计数动画",
defaultProps: () => ({ stats: [{ label: "", value: "", unit: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「数说学校」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Facts & Figures" },
{
key: "stats", label: "统计项", kind: "stats",
tip: "数值支持小数与「万/千」单位(如 3.2 + 万);滚动到可视区时数字从 0 滚动到目标值",
},
],
},
{
type: "featured_topics",
title: "专题大卡",
icon: "md-photos",
description: "招生/校庆/双一流等专题:大图 + 标题 + 描述 + 按钮,2~3 张并排",
defaultProps: () => ({ topicItems: [{ title: "", image: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「专题推荐」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Featured Topics" },
{
key: "items", label: "专题卡", kind: "topics",
tip: "图片先在「附件管理」上传后复制路径填入(/ 开头),建议尺寸 800×500",
},
],
},
{
type: "video_tour",
title: "视频/VR 校园",
icon: "md-play-circle",
description: "校园宣传片/VR 导览:封面大图 + 播放按钮,点击跳转视频地址",
defaultProps: () => ({ cover: "", url: "" }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「云端校园」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Virtual Tour" },
{
key: "cover", label: "封面图地址", kind: "text", maxlength: 500,
placeholder: "/yschcms/upload/… 或 https://…(附件管理上传后复制路径)",
},
{
key: "url", label: "视频/VR 地址", kind: "text", maxlength: 500,
placeholder: "https://… 或站内 / 路径,点击封面跳转",
},
],
},
{
type: "events",
title: "学术日历",
icon: "md-calendar",
description: "学术报告/活动:大日期块 + 活动名 + 地点;数据在「轮播管理 → 学术活动」维护",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「学术活动」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Academic Events" },
],
},
{
type: "people",
title: "名师风采",
icon: "md-contacts",
description: "圆头像 + 姓名 + 头衔 + 一句话简介卡片;人物数据在「人物管理」维护",
defaultProps: () => ({}),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「名师风采」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Our Faculty" },
{
key: "categoryId", label: "人物分组栏目", kind: "category",
tip: "可选;选择后仅展示挂在该栏目下的人物(人物编辑时指定所属栏目),不选 = 全部人物。人物的新增/编辑在「人物管理」页(/person)维护",
},
],
},
{
type: "badges",
title: "排名/学科徽章带",
icon: "md-ribbon",
description: "软科排名 / 学科评估 A+ / ESI 前 1% 等徽章图片 + 文字横排",
defaultProps: () => ({ badgeItems: [{ label: "", image: "" }] }),
propFields: [
{ key: "title", label: "区块标题", kind: "text", placeholder: "默认「实力声誉」" },
{ key: "subtitle", label: "英文副题", kind: "text", placeholder: "可选,如 Rankings" },
{
key: "badgeItems", label: "徽章列表", kind: "badges",
tip: "徽章图片建议透明底 PNG(附件管理上传后复制路径,/ 开头)",
},
],
},
];
export function blockDefOf(type: string): BlockTypeDef | undefined {
return BLOCK_TYPES.find(t => t.type === type);
}
/** 序列行摘要(形态/条数/来源一句话;namesOf 提供栏目名映射) */
export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string): string {
const parts: string[] = [];
if (block.type === "article_list") {
parts.push(styleLabel(block.style));
if (block.style !== "image_wall") {
parts.push(`${block.size ?? 8}条`);
}
} else if (block.type === "ticker") {
parts.push(block.source === "category" ? "指定栏目最新" : "全站置顶");
parts.push(`${block.size ?? 6}条`);
} else if (block.type === "gallery") {
parts.push(block.layout === "masonry" ? "线上展厅 · 瀑布流" : "线上展厅图集");
} else if (block.type === "links") {
parts.push(block.group ? `分组:${block.group}` : "全部分组");
} else if (block.type === "quick_links") {
const filled = (block.items || []).filter(i => (i.name || "").trim() || (i.url || "").trim());
parts.push(`${filled.length} 个入口`);
} else if (block.type === "notice_bar") {
parts.push(block.level === "warn" ? "醒目提示" : "普通提示");
const text = (block.text || "").trim();
if (text) {
parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text);
}
} else if (block.type === "stats_band") {
const filled = (block.stats || []).filter(st => (st.label || "").trim() || (st.value || "").trim());
parts.push(`${filled.length} 项数字`);
} else if (block.type === "featured_topics") {
const filled = (block.topicItems || []).filter(t => (t.title || "").trim());
parts.push(`${filled.length} 张专题卡`);
} else if (block.type === "video_tour") {
parts.push("封面 + 播放跳转");
} else if (block.type === "events") {
parts.push("学术活动(轮播管理维护)");
} else if (block.type === "people") {
parts.push(block.categoryId ? "按分组栏目过滤" : "全部人物");
} else if (block.type === "badges") {
const filled = (block.badgeItems || []).filter(b => (b.label || "").trim());
parts.push(`${filled.length} 个徽章`);
}
const ids = block.categoryIds || [];
if (ids.length) {
const names = ids.map(namesOf);
parts.push(`来源:${names.filter(Boolean).join("、") || `${ids.length} 个栏目`}`);
}
return parts.join(" · ");
}
+320
View File
@@ -0,0 +1,320 @@
/**
* 首页布局纯函数单测(docs/16 §4.7):parse/serialize/一键生成算法/校验。
* 守护契约:无 home_layout=回落(null);serialize 仅动 home_layout 键;
* genFromCategories 与渲染端 categoryBlocks 现状算法等价(style 映射 + 16/8 条 + ticker/gallery 收口)。
*/
import { describe, expect, it } from "vitest";
import { CategoryTreeNode } from "@/types/cms";
import {
HomeLayout,
LayoutBlock,
cleanBlock,
flattenListCategories,
genFromCategories,
parseHomeLayout,
serializeHomeLayout,
validateHomeLayout,
} from "./homeLayout";
function cat(id: number, name: string, type = 1, homeStyle?: string, children: CategoryTreeNode[] = []): CategoryTreeNode {
return { id, parentId: 0, name, type, homeStyle, sortNo: 0, status: 1, children };
}
describe("parseHomeLayout:容错解析(无键/非法→null 回落)", () => {
it("无配置/空串/非法 JSON/无 home_layout 键 → null", () => {
expect(parseHomeLayout(undefined)).toBeNull();
expect(parseHomeLayout("")).toBeNull();
expect(parseHomeLayout("not-json")).toBeNull();
expect(parseHomeLayout('{"logo":"/a.png"}')).toBeNull();
expect(parseHomeLayout('{"home_layout":"bad"}')).toBeNull();
expect(parseHomeLayout('{"home_layout":{"blocks":"bad"}}')).toBeNull();
});
it("合法布局解析:blocks 收敛为已知字段,未知 type 跳过,缺 id 补生成", () => {
const json = JSON.stringify({
theme_params: { primary: "#2d8cf0" },
home_layout: {
version: 1,
blocks: [
{ id: "b1", type: "article_list", title: "校园时讯", categoryIds: [12, 15], style: "news_grid", size: 16, background: true },
{ id: "b2", type: "ticker", source: "top", size: 6 },
{ type: "gallery", title: "线上展厅", subtitle: "Online Exhibition Hall" },
{ type: "richtext", html: "v2 未开放" },
],
},
});
const layout = parseHomeLayout(json)!;
expect(layout).not.toBeNull();
expect(layout.version).toBe(1);
expect(layout.blocks.length).toBe(3);
expect(layout.blocks[0]).toMatchObject({ id: "b1", type: "article_list", title: "校园时讯", categoryIds: [12, 15], style: "news_grid", size: 16, background: true, visible: true });
expect(layout.blocks[2].id).toMatch(/^b_/);
expect(layout.blocks.some((b: LayoutBlock) => (b as { type: string }).type === "richtext")).toBe(false);
});
it("visible:false 保留(保序下线),非字符串数字容错", () => {
const layout = parseHomeLayout(JSON.stringify({
home_layout: { blocks: [{ id: "b1", type: "links", visible: false, group: "常用链接" }] },
}))!;
expect(layout.blocks[0].visible).toBe(false);
expect(layout.blocks[0].group).toBe("常用链接");
});
});
describe("serializeHomeLayout:仅动 home_layout 键,其余键无损", () => {
it("写回布局并保留 theme_params/custom_css/logo 等其他键", () => {
const config = JSON.stringify({ logo: "/a.png", theme_params: { primary: "#2d8cf0" }, custom_css: ".x{}" });
const layout: HomeLayout = {
version: 1,
blocks: [{ id: "b1", type: "article_list", visible: true, title: "通知公告", categoryIds: [20, 21], style: "compact", size: 6 }],
};
const out = JSON.parse(serializeHomeLayout(config, layout));
expect(out.logo).toBe("/a.png");
expect(out.theme_params).toEqual({ primary: "#2d8cf0" });
expect(out.custom_css).toBe(".x{}");
expect(out.home_layout.blocks).toEqual([
{ id: "b1", type: "article_list", title: "通知公告", categoryIds: [20, 21], style: "compact", size: 6 },
]);
});
it("cleanBlock 序列化最小化:visible 仅 false 时写、默认 style/size 省略、background 仅 true 时写", () => {
expect(cleanBlock({ id: "b1", type: "article_list", visible: true, categoryIds: [1] })).toEqual({ id: "b1", type: "article_list", categoryIds: [1] });
expect(cleanBlock({ id: "b1", type: "article_list", visible: false, categoryIds: [1], style: "card", size: 8, background: true })).toEqual({
id: "b1", type: "article_list", visible: false, categoryIds: [1], size: 8, background: true,
});
expect(cleanBlock({ id: "b2", type: "ticker", visible: true, source: "category", categoryIds: [3] })).toEqual({
id: "b2", type: "ticker", source: "category", categoryIds: [3],
});
expect(cleanBlock({ id: "b3", type: "gallery", visible: true })).toEqual({ id: "b3", type: "gallery" });
expect(cleanBlock({ id: "b4", type: "links", visible: true, group: "常用" })).toEqual({ id: "b4", type: "links", group: "常用" });
});
it("layout=null 删除 home_layout 键(清除自定义布局→回落态);空 blocks 显式保留空布局", () => {
const config = JSON.stringify({ logo: "/a.png", home_layout: { version: 1, blocks: [{ id: "b", type: "links" }] } });
const cleared = JSON.parse(serializeHomeLayout(config, null));
expect(cleared).toEqual({ logo: "/a.png" });
const emptied = JSON.parse(serializeHomeLayout(config, { version: 1, blocks: [] }));
expect(emptied.home_layout).toEqual({ version: 1, blocks: [] });
});
it("parse→serialize 往返稳定(不改写无关键、不丢块)", () => {
const json = JSON.stringify({
logo: "/a.png",
home_layout: { version: 1, blocks: [{ id: "b1", type: "gallery", title: "展厅" }] },
});
const round = JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)));
expect(round).toEqual(JSON.parse(json));
});
});
describe("validateHomeLayout:与后端 SiteService 同语义", () => {
const ok: LayoutBlock = { id: "b", type: "article_list", visible: true, categoryIds: [1], size: 8 };
it("null 布局与合法布局均通过", () => {
expect(validateHomeLayout(null)).toEqual([]);
expect(validateHomeLayout({ version: 1, blocks: [ok, { id: "t", type: "ticker", visible: true }] })).toEqual([]);
});
it("size 越界 / 标题超长 / article_list 缺栏目 / 区块超量 报错", () => {
expect(validateHomeLayout({ version: 1, blocks: [{ ...ok, size: 0 }] }).join(" ")).toContain("条数");
expect(validateHomeLayout({ version: 1, blocks: [{ ...ok, size: 21 }] }).join(" ")).toContain("条数");
expect(validateHomeLayout({ version: 1, blocks: [{ ...ok, title: "x".repeat(101) }] }).join(" ")).toContain("100");
expect(validateHomeLayout({ version: 1, blocks: [{ id: "a", type: "article_list", visible: true }] }).join(" ")).toContain("数据栏目");
expect(validateHomeLayout({ version: 1, blocks: Array.from({ length: 31 }, (_, i) => ({ id: `b${i}`, type: "links" as const, visible: true })) }).join(" ")).toContain("30");
expect(validateHomeLayout({ version: 1, blocks: [{ id: "t", type: "ticker", visible: true, source: "category" }] }).join(" ")).toContain("指定栏目");
});
});
describe("quick_links / notice_bar(v2 手册 §2.8):装拆与校验须与后端 S3 同语义", () => {
const link = (name: string, url: string) => ({ name, url });
const quick = (items: LayoutBlock["items"]): LayoutBlock => ({ id: "q", type: "quick_links", visible: true, items });
const notice = (extra: Partial<LayoutBlock>): LayoutBlock => ({ id: "n", type: "notice_bar", visible: true, ...extra });
it("T-B-06 parseHomeLayout 接受两个新类型;normalizeBlock 还原 items/text/url/level 并剔除脏行", () => {
const json = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{
id: "q1", type: "quick_links", title: "服务入口", items: [
link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library"),
"脏项", { name: 1, url: 2 },
],
},
{ id: "n1", type: "notice_bar", text: "国庆放假", url: "/notice/gq", level: "warn" },
{ id: "n2", type: "notice_bar", text: "常规提示" },
],
},
});
const layout = parseHomeLayout(json)!;
expect(layout.blocks.length).toBe(3);
expect(layout.blocks[0]).toMatchObject({
id: "q1", type: "quick_links", title: "服务入口", visible: true,
items: [link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library")],
});
expect(layout.blocks[1]).toMatchObject({ text: "国庆放假", url: "/notice/gq", level: "warn" });
expect(layout.blocks[2].level).toBeUndefined();
// 未知类型仍被跳过(两个新类型入白名单不等于放开任意类型)
expect(parseHomeLayout(JSON.stringify({ home_layout: { blocks: [{ type: "quicklink" }] } }))!.blocks).toEqual([]);
});
it("T-B-07 cleanBlock:两个新类型只写业务键、缺省值不落键、空入口行剔除", () => {
expect(cleanBlock(quick([link("办事大厅", "https://x.edu.cn"), link("", "")]))).toEqual({
id: "q", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")],
});
expect(cleanBlock(quick([]))).toEqual({ id: "q", type: "quick_links" });
expect(cleanBlock({ ...quick([link("图", "/lib")]), visible: false })).toEqual({
id: "q", type: "quick_links", visible: false, items: [link("图", "/lib")],
});
expect(cleanBlock(notice({ text: " 放假通知 ", url: "/a", level: "warn" }))).toEqual({
id: "n", type: "notice_bar", text: "放假通知", url: "/a", level: "warn",
});
// level=info 是缺省值 → 不写;url 空 → 不写
expect(cleanBlock(notice({ text: "提示", level: "info", url: "" }))).toEqual({ id: "n", type: "notice_bar", text: "提示" });
});
it("T-B-07b parse→serialize 往返稳定(新区块不被改写)", () => {
const json = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{ id: "q1", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")] },
{ id: "n1", type: "notice_bar", text: "放假通知", url: "/notice", level: "warn" },
],
},
});
expect(JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)))).toEqual(JSON.parse(json));
});
it("T-B-08 validateHomeLayout 与后端同语义:必填 / 12 项 / 20 字 / 200 字 / url 前缀 / level 白名单", () => {
expect(validateHomeLayout({ version: 1, blocks: [quick([link("办事大厅", "https://x.edu.cn")]), notice({ text: "通知" })] })).toEqual([]);
const joined = (blocks: LayoutBlock[]) => validateHomeLayout({ version: 1, blocks }).join(" ");
expect(joined([quick([])])).toContain("入口");
expect(joined([quick(Array.from({ length: 13 }, (_, i) => link(`入口${i}`, `/e${i}`)))])).toContain("12");
expect(joined([quick([link("长".repeat(21), "/x")])])).toContain("20");
expect(joined([quick([link("", "/x")])])).toContain("名称");
expect(joined([quick([link("办事大厅", "")])])).toContain("http(s)://");
expect(joined([quick([link("办事大厅", "javascript:alert(1)")])])).toContain("http(s)://");
expect(joined([quick([link("办事大厅", "lib.edu.cn")])])).toContain("http(s)://");
expect(joined([notice({})])).toContain("公告正文");
expect(joined([notice({ text: "告".repeat(201) })])).toContain("200");
expect(joined([notice({ text: "x", url: "javascript:alert(1)" })])).toContain("http(s)://");
expect(joined([notice({ text: "x", level: "danger" })])).toContain("info");
});
});
describe("genFromCategories:一键生成与现状渲染等价", () => {
const tree: CategoryTreeNode[] = [
cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]),
cat(2, "媒体聚焦", 1, "media_focus"),
cat(3, "学校概况", 2),
cat(4, "通知公告", 1),
];
it("序列 = ticker(置顶6条) + 列表型一级栏目(style 取 home_style、16/8 条、仅列表型入列)+ gallery", () => {
const layout = genFromCategories(tree, JSON.stringify({ gallery_title: "线上展厅", gallery_subtitle: "Online Exhibition Hall" }));
expect(layout.blocks.length).toBe(5);
expect(layout.blocks[0]).toMatchObject({ type: "ticker", source: "top", title: "要闻速递", size: 6 });
expect(layout.blocks[1]).toMatchObject({ type: "article_list", title: "校园时讯", categoryIds: [1], style: "news_grid", size: 16 });
expect(layout.blocks[2]).toMatchObject({ type: "article_list", title: "媒体聚焦", categoryIds: [2], style: "media_focus", size: 8 });
expect(layout.blocks[3]).toMatchObject({ type: "article_list", title: "通知公告", categoryIds: [4], style: "card", size: 8 });
expect(layout.blocks[4]).toMatchObject({ type: "gallery", title: "线上展厅", subtitle: "Online Exhibition Hall" });
// 子栏目不重复出块(聚合在渲染端按子孙收集);单页/外链栏目不入列
expect(layout.blocks.some(b => b.categoryIds?.includes(11))).toBe(false);
expect(layout.blocks.some(b => b.title === "学校概况")).toBe(false);
});
it("无旧键配置时 gallery 用缺省标题;旧键非法时同样缺省", () => {
const layout = genFromCategories([], "");
const gallery = layout.blocks.find(b => b.type === "gallery")!;
expect(gallery.title).toBe("线上展厅");
expect(gallery.subtitle).toBe("Online Exhibition Hall");
expect(genFromCategories([], "bad-json").blocks.find(b => b.type === "gallery")!.title).toBe("线上展厅");
});
});
describe("flattenListCategories:扁平化仅列表型(选择器数据源)", () => {
const tree: CategoryTreeNode[] = [
cat(1, "新闻", 1, undefined, [cat(11, "校内", 1), cat(12, "单页", 2)]),
cat(2, "概况", 2),
];
it("深度缩进 + 仅 type=1", () => {
const flat = flattenListCategories(tree);
expect(flat).toEqual([
{ id: 1, name: "新闻", depth: 0 },
{ id: 11, name: "校内", depth: 1 },
]);
});
});
/* ===== docs/20 D 组件库:新类型解析/序列化/校验 ===== */
describe("homeLayout - D 组件(docs/20)", () => {
const D_LAYOUT = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{ id: "s1", type: "stats_band", title: "数说学校", stats: [
{ label: "在校生", value: "3.2", unit: "万", icon: "xiaoli" },
{ label: "教职工", value: "2800" },
] },
{ id: "t1", type: "featured_topics", items: [
{ title: "2026 招生专题", image: "/cms/upload/topic1.jpg", description: "欢迎报考", ctaText: "进入专题", ctaUrl: "https://zs.example.edu.cn" },
] },
{ id: "v1", type: "video_tour", cover: "/cms/upload/vr.jpg", url: "https://vr.example.edu.cn" },
{ id: "e1", type: "events" },
{ id: "p1", type: "people", categoryId: 12 },
{ id: "b1", type: "badges", badgeItems: [{ label: "软科排名", desc: "前100强", image: "/cms/upload/badge.png" }] },
{ id: "g1", type: "gallery", layout: "masonry" },
{ id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "/service", icon: "banshi" }] },
],
},
});
it("parse:D 新类型全量解析(stats/topicItems→items 契约/video/people.categoryId/gallery.layout/icon)", () => {
const layout = parseHomeLayout(D_LAYOUT)!;
expect(layout.blocks).toHaveLength(8);
const s1 = layout.blocks.find(b => b.id === "s1")!;
expect(s1.stats?.[0]).toMatchObject({ label: "在校生", value: "3.2", unit: "万", icon: "xiaoli" });
const t1 = layout.blocks.find(b => b.id === "t1")!;
expect(t1.topicItems?.[0].ctaUrl).toBe("https://zs.example.edu.cn");
const v1 = layout.blocks.find(b => b.id === "v1")!;
expect(v1.cover).toBe("/cms/upload/vr.jpg");
const p1 = layout.blocks.find(b => b.id === "p1")!;
expect(p1.categoryId).toBe(12);
const g1 = layout.blocks.find(b => b.id === "g1")!;
expect(g1.layout).toBe("masonry");
const q1 = layout.blocks.find(b => b.id === "q1")!;
expect(q1.items?.[0].icon).toBe("banshi");
});
it("roundtrip:D 类型 serialize→parse 无损(topicItems 序列化为契约键 items;空行剔除)", () => {
const once = parseHomeLayout(D_LAYOUT)!;
const twice = parseHomeLayout(serializeHomeLayout("{}", once))!;
const t1 = twice.blocks.find(b => b.id === "t1")!;
expect(JSON.stringify(t1.topicItems)).toBe(JSON.stringify(once.blocks.find(b => b.id === "t1")!.topicItems));
const b1 = twice.blocks.find(b => b.id === "b1")!;
expect(b1.badgeItems?.[0].label).toBe("软科排名");
// quick_links icon 随行序列化
const q1 = twice.blocks.find(b => b.id === "q1")!;
expect(q1.items?.[0].icon).toBe("banshi");
// gallery masonry 序列化
const g1 = twice.blocks.find(b => b.id === "g1")!;
expect(g1.layout).toBe("masonry");
});
it("validate:D 各类缺失/超限/非法值给出可读错误", () => {
const mk = (blocks: unknown[]) =>
({ version: 1, blocks: blocks as never[] });
expect(validateHomeLayout(mk([{ id: "x", type: "stats_band", visible: true }]))[0]).toContain("统计项");
expect(validateHomeLayout(mk([{ id: "x", type: "stats_band", visible: true, stats: [{ label: "在校生", value: "12345678901" }] }]))[0]).toContain("数值");
expect(validateHomeLayout(mk([{ id: "x", type: "featured_topics", visible: true }]))[0]).toContain("专题卡");
expect(validateHomeLayout(mk([{ id: "x", type: "featured_topics", visible: true, topicItems: [{ title: "专题", image: "javascript:alert(1)" }] }]))[0]).toContain("图片");
expect(validateHomeLayout(mk([{ id: "x", type: "video_tour", visible: true }]))[0]).toContain("封面");
expect(validateHomeLayout(mk([{ id: "x", type: "video_tour", visible: true, cover: "/cms/upload/a.jpg" }]))[0]).toContain("视频");
expect(validateHomeLayout(mk([{ id: "x", type: "badges", visible: true }]))[0]).toContain("徽章");
expect(validateHomeLayout(mk([{ id: "x", type: "gallery", visible: true, layout: "zigzag" }]))[0]).toContain("布局");
// 合法布局零错误
expect(validateHomeLayout(mk([{ id: "x", type: "gallery", visible: true, layout: "masonry" }]))).toEqual([]);
});
});
+680
View File
@@ -0,0 +1,680 @@
/**
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、
* title/subtitle ≤100 字、quick_links items ≤12 项(name ≤20 字、url 须 http(s):// 或 / 开头)、
* notice_bar text 必填 ≤200 字、level ∈ {info,warn};
* 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
*/
import { CategoryTreeNode } from "@/types/cms";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致;docs/20 D 新增后 12 种) */
export type BlockType =
| "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar"
// docs/20 D-1~D-9
| "stats_band" | "featured_topics" | "video_tour" | "events" | "people" | "badges";
/** quick_links 单个入口(v2 手册 §2.8;D-2 加可选 icon 内置图标标识符) */
export interface QuickLinkItem {
name: string;
url: string;
icon?: string;
}
/** stats_band 单个统计项(D-1:数字计数带) */
export interface StatItem {
label: string;
value: string;
unit?: string;
icon?: string;
}
/** featured_topics 单张专题卡(D-3) */
export interface TopicItem {
title: string;
description?: string;
image: string;
ctaText?: string;
ctaUrl?: string;
}
/** badges 单个徽章(D-9) */
export interface BadgeItem {
label: string;
desc?: string;
image: string;
}
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
export const LIST_STYLES: { value: string; label: string }[] = [
{ value: "card", label: "默认卡(头条+列表)" },
{ value: "news_grid", label: "双栏新闻卡" },
{ value: "media_focus", label: "媒体聚焦" },
{ value: "compact", label: "纯文字列表" },
{ value: "image_wall", label: "图片墙(有封面文章)" },
];
export function styleLabel(style: string | undefined): string {
return LIST_STYLES.find(s => s.value === style)?.label || "默认卡(头条+列表)";
}
/** 布局区块(编辑器内存形态:含 visible 运行时键;序列化时仅落业务键) */
export interface LayoutBlock {
id: string;
type: BlockType;
/** false = 保序下线(渲染端跳过) */
visible: boolean;
title?: string;
/** gallery 英文副题 */
subtitle?: string;
/** article_list / ticker(source=category):数据栏目(可多选,渲染端聚合子孙) */
categoryIds?: number[];
/** article_list 展示形态,缺省 card */
style?: string;
/** 条数(article_list 缺省 8、ticker 缺省 6;gallery 不设 = 全部) */
size?: number;
/** article_list news_grid 背景大图开关(取站点配置 news_bg_image 旧键) */
background?: boolean;
/** ticker 数据源:top=全站置顶(缺省)| category=指定栏目最新 */
source?: string;
/** links 分组名(空=全部分组) */
group?: string;
/** quick_links 入口列表(≤12 项;name ≤20 字、url 为 http(s):// 或站内 / 路径) */
items?: QuickLinkItem[];
/** notice_bar 公告正文(必填,≤200 字) */
text?: string;
/** notice_bar 跳转地址(可选) */
url?: string;
/** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */
level?: string;
// ===== docs/20 D 组件库新增字段 =====
/** gallery 布局(D-8):grid 规整网格(缺省)| masonry 瀑布流 */
layout?: string;
/** quick_links 统一图标形状随主题参数,不落区块 */
/** stats_band 统计项(D-1,1~8 项) */
stats?: StatItem[];
/** featured_topics 专题卡(D-3,编辑器模型;序列化键为 items,与后端契约对齐) */
topicItems?: TopicItem[];
/** video_tour(D-5) */
cover?: string;
/** people 数据栏目(D-7,可空 = 全部人物) */
categoryId?: number;
/** badges 徽章(D-9,1~8 项) */
badgeItems?: BadgeItem[];
}
export interface HomeLayout {
version: number;
blocks: LayoutBlock[];
}
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8 + docs/20 D) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = [
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
"stats_band", "featured_topics", "video_tour", "events", "people", "badges",
];
export const SIZE_MIN = 1;
export const SIZE_MAX = 20;
export const TITLE_MAX = 100;
export const MAX_BLOCKS = 30;
export const QUICK_LINK_MAX = 12;
export const LINK_NAME_MAX = 20;
export const NOTICE_TEXT_MAX = 200;
export const NOTICE_LEVELS = ["info", "warn"];
// ===== docs/20 D 组件库约束(与后端 SiteService 同契约) =====
export const STATS_MAX = 8;
export const TOPICS_MAX = 6;
export const BADGES_MAX = 8;
export const GALLERY_LAYOUTS = ["grid", "masonry"];
/** 内置图标标识符(D-2:与门户骨架 icon fragment 同 key 集) */
export const ICON_KEY_PATTERN = /^[a-z0-9-]{1,30}$/;
export const ASSET_URL_PATTERN = /^(\/.+|https?:\/\/\S+)$/;
/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */
const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/;
export function genBlockId(): string {
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
}
function asObject(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/**
* configJson → 布局(容错解析):无键 / 非法 JSON / home_layout 非对象 / blocks 非数组 → null(=回落现状)。
* 单块收敛为已知字段(未知键丢弃);type 不在白名单的块跳过。
*/
export function parseHomeLayout(configJson: string | undefined | null): HomeLayout | null {
if (!configJson || !configJson.trim()) {
return null;
}
let root: Record<string, unknown> | null = null;
try {
root = asObject(JSON.parse(configJson));
} catch {
return null;
}
if (!root) {
return null;
}
const hl = asObject(root["home_layout"]);
if (!hl || !Array.isArray(hl["blocks"])) {
return null;
}
const blocks: LayoutBlock[] = [];
for (const raw of hl["blocks"] as unknown[]) {
const obj = asObject(raw);
const type = typeof obj?.["type"] === "string" ? (obj["type"] as string) : "";
if (!BLOCK_TYPES_ALLOWED.includes(type as BlockType)) {
continue;
}
blocks.push(normalizeBlock(obj as Record<string, unknown>, type as BlockType));
}
return { version: typeof hl["version"] === "number" ? hl["version"] : HOME_LAYOUT_VERSION, blocks };
}
function toIdList(v: unknown): number[] | undefined {
if (!Array.isArray(v)) {
return undefined;
}
const ids = v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
return ids.length ? ids : undefined;
}
/** 入口列表容错还原:非对象项(字符串/数字/null)与非字符串 name/url 的脏行整行丢弃 */
function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
if (!Array.isArray(v)) {
return undefined;
}
const items: QuickLinkItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const name = typeof obj?.["name"] === "string" ? (obj["name"] as string).trim() : null;
const url = typeof obj?.["url"] === "string" ? (obj["url"] as string).trim() : null;
if (name === null || url === null) {
continue;
}
const item: QuickLinkItem = { name, url };
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) {
item.icon = obj["icon"] as string;
}
items.push(item);
}
return items.length ? items : undefined;
}
/** stats_band 统计项容错还原(D-1) */
function toStatItems(v: unknown): StatItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: StatItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
const value = typeof obj?.["value"] === "string" ? (obj["value"] as string).trim() : "";
if (!label || !value) continue;
const item: StatItem = { label, value };
if (typeof obj?.["unit"] === "string" && (obj["unit"] as string).trim()) item.unit = (obj["unit"] as string).trim();
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) item.icon = obj["icon"] as string;
items.push(item);
}
return items.length ? items : undefined;
}
/** featured_topics 专题卡容错还原(D-3) */
function toTopicItems(v: unknown): TopicItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: TopicItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const title = typeof obj?.["title"] === "string" ? (obj["title"] as string).trim() : "";
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
if (!title || !image) continue;
const item: TopicItem = { title, image };
if (typeof obj?.["description"] === "string" && (obj["description"] as string).trim()) item.description = (obj["description"] as string).trim();
if (typeof obj?.["ctaText"] === "string" && (obj["ctaText"] as string).trim()) item.ctaText = (obj["ctaText"] as string).trim();
if (typeof obj?.["ctaUrl"] === "string" && (obj["ctaUrl"] as string).trim()) item.ctaUrl = (obj["ctaUrl"] as string).trim();
items.push(item);
}
return items.length ? items : undefined;
}
/** badges 徽章容错还原(D-9,序列化键 badgeItems 防与 featured_topics.items 语义混淆) */
function toBadgeItems(v: unknown): BadgeItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: BadgeItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
if (!label || !image) continue;
const item: BadgeItem = { label, image };
if (typeof obj?.["desc"] === "string" && (obj["desc"] as string).trim()) item.desc = (obj["desc"] as string).trim();
items.push(item);
}
return items.length ? items : undefined;
}
function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
const block: LayoutBlock = {
id: typeof obj["id"] === "string" && obj["id"] ? obj["id"] : genBlockId(),
type,
visible: obj["visible"] !== false,
};
if (typeof obj["title"] === "string") block.title = obj["title"];
if (typeof obj["subtitle"] === "string") block.subtitle = obj["subtitle"];
if (typeof obj["style"] === "string") block.style = obj["style"];
if (size !== undefined) block.size = size;
if (typeof obj["background"] === "boolean") block.background = obj["background"];
if (typeof obj["source"] === "string") block.source = obj["source"];
if (typeof obj["group"] === "string") block.group = obj["group"];
if (type === "quick_links") {
const items = toLinkItems(obj["items"]);
if (items) block.items = items;
}
if (type === "notice_bar") {
if (typeof obj["text"] === "string") block.text = obj["text"];
if (typeof obj["url"] === "string") block.url = obj["url"];
if (typeof obj["level"] === "string") block.level = obj["level"];
}
// ===== docs/20 D =====
if (typeof obj["layout"] === "string") block.layout = obj["layout"];
if (typeof obj["cover"] === "string") block.cover = obj["cover"];
if (typeof obj["categoryId"] === "number" && Number.isFinite(obj["categoryId"]) && (obj["categoryId"] as number) > 0) {
block.categoryId = obj["categoryId"] as number;
}
if (type === "stats_band") {
const stats = toStatItems(obj["stats"]);
if (stats) block.stats = stats;
}
if (type === "featured_topics") {
const items = toTopicItems(obj["items"]);
if (items) block.topicItems = items;
}
if (type === "badges") {
const items = toBadgeItems(obj["badgeItems"]);
if (items) block.badgeItems = items;
}
const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids;
return block;
}
/** 单块 → 序列化对象:只写与类型相关的业务键(visible 仅 false 时写,size 仅显式设置时写) */
export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
const out: Record<string, unknown> = { id: block.id, type: block.type };
if (block.visible === false) {
out["visible"] = false;
}
const title = (block.title || "").trim();
if (title) {
out["title"] = title;
}
if (block.type === "article_list") {
if (block.categoryIds && block.categoryIds.length) {
out["categoryIds"] = block.categoryIds;
}
if (block.style && block.style !== "card") {
out["style"] = block.style;
}
if (block.size != null) {
out["size"] = block.size;
}
if (block.background) {
out["background"] = true;
}
} else if (block.type === "ticker") {
if (block.source && block.source !== "top") {
out["source"] = block.source;
}
if (block.categoryIds && block.categoryIds.length) {
out["categoryIds"] = block.categoryIds;
}
if (block.size != null) {
out["size"] = block.size;
}
} else if (block.type === "gallery") {
const subtitle = (block.subtitle || "").trim();
if (subtitle) {
out["subtitle"] = subtitle;
}
if (block.layout === "masonry") {
out["layout"] = "masonry";
}
} else if (block.type === "links") {
const group = (block.group || "").trim();
if (group) {
out["group"] = group;
}
} else if (block.type === "quick_links") {
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备);icon 可选随行
const items = (block.items || [])
.map(i => {
const row: Record<string, string> = { name: (i.name || "").trim(), url: (i.url || "").trim() };
if (i.icon && ICON_KEY_PATTERN.test(i.icon)) {
row["icon"] = i.icon;
}
return row;
})
.filter(i => i["name"] || i["url"]);
if (items.length) {
out["items"] = items;
}
} else if (block.type === "notice_bar") {
const text = (block.text || "").trim();
if (text) {
out["text"] = text;
}
const url = (block.url || "").trim();
if (url) {
out["url"] = url;
}
if (block.level === "warn") {
out["level"] = "warn";
}
} else if (block.type === "stats_band") {
const stats = (block.stats || []).filter(st => st.label.trim() && st.value.trim());
if (stats.length) {
out["stats"] = stats;
}
} else if (block.type === "featured_topics") {
const items = (block.topicItems || []).filter(t => t.title.trim() && t.image.trim());
if (items.length) {
out["items"] = items;
}
} else if (block.type === "video_tour") {
const cover = (block.cover || "").trim();
const url = (block.url || "").trim();
if (cover) out["cover"] = cover;
if (url) out["url"] = url;
} else if (block.type === "people") {
if (block.categoryId) {
out["categoryId"] = block.categoryId;
}
} else if (block.type === "badges") {
const items = (block.badgeItems || []).filter(b => b.label.trim() && b.image.trim());
if (items.length) {
out["badgeItems"] = items;
}
}
return out;
}
/**
* 布局写回 configJson(仅增删改 home_layout 键,其余键原样保留)。
* layout=null = 清除自定义布局(删除键,回到回落态);空 blocks = 显式空首页(写空数组,不删键)。
*/
export function serializeHomeLayout(configJson: string | undefined | null, layout: HomeLayout | null): string {
let config: Record<string, unknown> = {};
if (configJson && configJson.trim()) {
try {
const parsed = JSON.parse(configJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
config = { ...parsed };
}
} catch {
// 非法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
}
if (!layout) {
delete config["home_layout"];
} else {
config["home_layout"] = {
version: layout.version || HOME_LAYOUT_VERSION,
blocks: layout.blocks.map(cleanBlock),
};
}
return JSON.stringify(config);
}
/** 校验返回错误消息列表(空数组=通过;与后端 validateHomeLayout 同语义) */
export function validateHomeLayout(layout: HomeLayout | null): string[] {
const errors: string[] = [];
if (!layout) {
return errors;
}
if (layout.blocks.length > MAX_BLOCKS) {
errors.push(`区块数量不能超过 ${MAX_BLOCKS} 个`);
}
layout.blocks.forEach((b, i) => {
const no = `第 ${i + 1} 个区块(${b.title || b.type})`;
if (!BLOCK_TYPES_ALLOWED.includes(b.type)) {
errors.push(`${no}:类型不合法`);
}
if ((b.title || "").trim().length > TITLE_MAX || (b.subtitle || "").trim().length > TITLE_MAX) {
errors.push(`${no}:标题/副题不能超过 ${TITLE_MAX} 字`);
}
if (b.size != null && (b.size < SIZE_MIN || b.size > SIZE_MAX)) {
errors.push(`${no}:条数须在 ${SIZE_MIN}~${SIZE_MAX} 之间`);
}
if (b.type === "article_list" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:请选择数据栏目`);
}
if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:指定栏目模式请选择栏目`);
}
if (b.type === "quick_links") {
const items = b.items || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 个入口`);
} else if (items.length > QUICK_LINK_MAX) {
errors.push(`${no}:入口数量不能超过 ${QUICK_LINK_MAX} 个`);
}
items.forEach(item => {
const name = (item.name || "").trim();
const linkUrl = (item.url || "").trim();
if (!name) {
errors.push(`${no}:入口名称不能为空`);
} else if (name.length > LINK_NAME_MAX) {
errors.push(`${no}:入口名称不能超过 ${LINK_NAME_MAX} 字`);
}
if (!LINK_URL_PATTERN.test(linkUrl)) {
errors.push(`${no}:入口「${name || linkUrl}」地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
});
}
if (b.type === "notice_bar") {
const text = (b.text || "").trim();
if (!text) {
errors.push(`${no}:请填写公告正文`);
} else if (text.length > NOTICE_TEXT_MAX) {
errors.push(`${no}:公告正文不能超过 ${NOTICE_TEXT_MAX} 字`);
}
const linkUrl = (b.url || "").trim();
if (linkUrl && !LINK_URL_PATTERN.test(linkUrl)) {
errors.push(`${no}:跳转地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
if (b.level != null && !NOTICE_LEVELS.includes(b.level)) {
errors.push(`${no}:提示级别只能是 info 或 warn`);
}
}
// ===== docs/20 D 组件校验(与后端 SiteService 同语义) =====
if (b.type === "gallery" && b.layout != null && !GALLERY_LAYOUTS.includes(b.layout)) {
errors.push(`${no}:画廊布局只能是 grid 或 masonry`);
}
if (b.type === "stats_band") {
const stats = b.stats || [];
if (!stats.length) {
errors.push(`${no}:请至少填写 1 个统计项`);
} else if (stats.length > STATS_MAX) {
errors.push(`${no}:统计项不能超过 ${STATS_MAX} 个`);
}
stats.forEach(st => {
const label = (st.label || "").trim();
const value = (st.value || "").trim();
if (!label) {
errors.push(`${no}:统计项名称不能为空`);
} else if (label.length > 20) {
errors.push(`${no}:统计项名称不能超过 20 字`);
}
if (!value) {
errors.push(`${no}:统计项数值不能为空`);
} else if (value.length > 10) {
errors.push(`${no}:统计项数值不能超过 10 字`);
}
if ((st.unit || "").length > 10) {
errors.push(`${no}:统计项单位不能超过 10 字`);
}
if (st.icon && !ICON_KEY_PATTERN.test(st.icon)) {
errors.push(`${no}:统计项图标标识不合法`);
}
});
}
if (b.type === "featured_topics") {
const items = b.topicItems || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 张专题卡`);
} else if (items.length > TOPICS_MAX) {
errors.push(`${no}:专题卡不能超过 ${TOPICS_MAX} 张`);
}
items.forEach(t => {
const title = (t.title || "").trim();
const image = (t.image || "").trim();
if (!title) {
errors.push(`${no}:专题卡标题不能为空`);
} else if (title.length > 50) {
errors.push(`${no}:专题卡标题不能超过 50 字`);
}
if ((t.description || "").length > 200) {
errors.push(`${no}:专题卡描述不能超过 200 字`);
}
if (!image) {
errors.push(`${no}:专题卡必须上传图片`);
} else if (!ASSET_URL_PATTERN.test(image)) {
errors.push(`${no}:专题卡图片须为上传路径(/ 开头)或 http(s) 地址`);
}
if ((t.ctaText || "").length > 20) {
errors.push(`${no}:按钮文字不能超过 20 字`);
}
const ctaUrl = (t.ctaUrl || "").trim();
if (ctaUrl && !LINK_URL_PATTERN.test(ctaUrl)) {
errors.push(`${no}:按钮地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
});
}
if (b.type === "video_tour") {
const cover = (b.cover || "").trim();
const url = (b.url || "").trim();
if (!cover) {
errors.push(`${no}:请上传视频封面图`);
} else if (!ASSET_URL_PATTERN.test(cover)) {
errors.push(`${no}:封面图须为上传路径(/ 开头)或 http(s) 地址`);
}
if (!url) {
errors.push(`${no}:请填写视频/VR 页面地址`);
} else if (!LINK_URL_PATTERN.test(url)) {
errors.push(`${no}:视频地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
}
if (b.type === "badges") {
const items = b.badgeItems || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 个徽章`);
} else if (items.length > BADGES_MAX) {
errors.push(`${no}:徽章不能超过 ${BADGES_MAX} 个`);
}
items.forEach(bd => {
const label = (bd.label || "").trim();
const image = (bd.image || "").trim();
if (!label) {
errors.push(`${no}:徽章名称不能为空`);
} else if (label.length > 20) {
errors.push(`${no}:徽章名称不能超过 20 字`);
}
if ((bd.desc || "").length > 50) {
errors.push(`${no}:徽章说明不能超过 50 字`);
}
if (!image) {
errors.push(`${no}:徽章必须上传图片`);
} else if (!ASSET_URL_PATTERN.test(image)) {
errors.push(`${no}:徽章图片须为上传路径(/ 开头)或 http(s) 地址`);
}
});
}
});
return errors;
}
/** 树展开为可勾选的扁平列表(仅列表型栏目可作数据源;外链/单页排除) */
export interface FlatCategory {
id: number;
name: string;
depth: number;
}
export function flattenListCategories(tree: CategoryTreeNode[], depth = 0, out: FlatCategory[] = []): FlatCategory[] {
(tree || []).forEach(node => {
if (node.type === 1) {
out.push({ id: node.id as number, name: node.name, depth });
}
if (node.children && node.children.length) {
flattenListCategories(node.children, depth + 1, out);
}
});
return out;
}
/** 树内全部栏目 id(布局数据源合法性参考) */
export function allCategoryIds(tree: CategoryTreeNode[]): number[] {
const ids: number[] = [];
(tree || []).forEach(node => {
if (node.id != null) {
ids.push(node.id);
}
ids.push(...allCategoryIds(node.children || []));
});
return ids;
}
/**
* 「从当前栏目一键生成」(docs/16 §4.3 首用救生索):复刻渲染端 categoryBlocks 现状算法,
* 生成与旧首页等价的初始序列(ticker → 各列表栏目区块(home_style→style、16/8 条)→ gallery),
* 用户在此基础上删改,心智从「从零搭」变成「修剪」。
*/
export function genFromCategories(tree: CategoryTreeNode[], configJson: string | undefined | null): HomeLayout {
let galleryTitle = "线上展厅";
let gallerySubtitle = "Online Exhibition Hall";
if (configJson && configJson.trim()) {
try {
const cfg = JSON.parse(configJson);
if (cfg && typeof cfg === "object") {
if (typeof cfg["gallery_title"] === "string" && cfg["gallery_title"].trim()) {
galleryTitle = cfg["gallery_title"];
}
if (typeof cfg["gallery_subtitle"] === "string" && cfg["gallery_subtitle"].trim()) {
gallerySubtitle = cfg["gallery_subtitle"];
}
}
} catch {
// 非法配置按缺省标题
}
}
const blocks: LayoutBlock[] = [
{ id: genBlockId(), type: "ticker", visible: true, title: "要闻速递", source: "top", size: 6 },
];
(tree || []).forEach(node => {
if (node.type !== 1 || node.id == null) {
return;
}
blocks.push({
id: genBlockId(),
type: "article_list",
visible: true,
title: node.name,
categoryIds: [node.id],
style: node.homeStyle || "card",
size: node.homeStyle === "news_grid" ? 16 : 8,
});
});
blocks.push({
id: genBlockId(),
type: "gallery",
visible: true,
title: galleryTitle,
subtitle: gallerySubtitle,
});
return { version: HOME_LAYOUT_VERSION, blocks };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* D-2(docs/20)内置通用 SVG 图标集(管理端选择器用)。
* 与门户骨架 themes/common/fragments/home-blocks.html :: icon(name) 同 key 同 path——
* 两端各持一份(Thymeleaf fragment / Vue 组件无法共享渲染层),改图标须两侧同步。
* 24x24 线条风格:stroke=currentColor,fill=none。
*/
export interface IconDef {
key: string;
label: string;
/** path d 列表(含 circle 用 path 近似或直接多 path) */
paths: string[];
}
export const ICON_DEFS: IconDef[] = [
{ key: "zhaosheng", label: "招生", paths: ["M3 9.5 12 4.5 21 9.5 12 14.5 3 9.5z", "M7 12v4.5c0 1 2.2 2 5 2s5-1 5-2V12", "M21 9.5V15"] },
{ key: "jiuye", label: "就业", paths: ["M3.5 8h17a1.5 1.5 0 0 1 1.5 1.5v8.5a2 2 0 0 1-2 2H4a.5.5 0 0 1-.5-.5z", "M9 8V6.2C9 5.5 9.6 5 10.3 5h3.4c.7 0 1.3.5 1.3 1.2V8", "M3.5 12.5h17"] },
{ key: "tushuguan", label: "图书馆", paths: ["M4 19.5 4.9 6.5 11 5v15", "M11 20h9V8l-7.9-1.5", "M4 19.5h7", "M14.5 11.5h3M14.5 15h3"] },
{ key: "banshi", label: "办事大厅", paths: ["M4 5.5h16v10H9.5L5 19.2V15.5H4z"] },
{ key: "xinxi", label: "信息公开", paths: ["M2.5 12C5 7.5 8.3 5.5 12 5.5S19 7.5 21.5 12C19 16.5 15.7 18.5 12 18.5S5 16.5 2.5 12z", "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"] },
{ key: "xiaoyou", label: "校友", paths: ["M6 8.5a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5", "M14.8 9.5a2.2 2.2 0 1 0 4.4 0 2.2 2.2 0 0 0-4.4 0z", "M16 14.2c2.6.3 4.5 2.1 4.5 4.8"] },
{ key: "youjian", label: "邮箱", paths: ["M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z", "m3.5 7 8.5 6 8.5-6"] },
{ key: "dianhua", label: "电话", paths: ["M5 4h3.5l1.7 4.3-2.3 1.8a12.5 12.5 0 0 0 5.9 5.9l1.8-2.3L20 15.4V19a1.5 1.5 0 0 1-1.6 1.5C10 20 4 14 3.5 5.6A1.5 1.5 0 0 1 5 4z"] },
{ key: "ditu", label: "地图/定位", paths: ["M12 21.5c4.2-5 6.8-8 6.8-11.5a6.8 6.8 0 1 0-13.6 0c0 3.5 2.6 6.5 6.8 11.5z", "M9.6 9.7a2.4 2.4 0 1 0 4.8 0 2.4 2.4 0 0 0-4.8 0z"] },
{ key: "xinwen", label: "新闻", paths: ["M4.5 5h15a1.5 1.5 0 0 1 1.5 1.5v11A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11A1.5 1.5 0 0 1 4.5 5z", "M6.5 9h6M6.5 12.5h6M6.5 16h6", "M15.5 9h3v7.5h-3z"] },
{ key: "rili", label: "日历", paths: ["M5.5 5h13a2 2 0 0 1 2 2v11.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z", "M3.5 10h17", "M8 3v4M16 3v4"] },
{ key: "shipin", label: "视频", paths: ["M3.5 12a8.5 8.5 0 1 0 17 0 8.5 8.5 0 0 0-17 0z", "M10 8.5 16 12l-6 3.5z"] },
{ key: "weixin", label: "微信", paths: ["M9.5 5.5C6 5.5 3 7.8 3 10.7c0 1.6.9 3 2.3 4L5 17l2.6-1.3c.6.2 1.3.3 1.9.3", "M21.5 14.8c0-2.4-2.3-4.3-5.1-4.3s-5.1 1.9-5.1 4.3 2.3 4.3 5.1 4.3c.6 0 1.2-.1 1.7-.3L20.5 20l-.5-1.7c.9-.8 1.5-2.1 1.5-3.5z"] },
{ key: "weibo", label: "微博", paths: ["M10.5 19.5c-3.5 0-6.5-2-6.5-4.7 0-2.9 3.3-5.6 6.8-5.6 3 0 6.5 1.6 6.5 4.2 0 2.3-1.9 4.3-4.4 4.3-1.8 0-3.6-.8-3.6-2 0-.8.7-1.5 1.7-1.5.9 0 1.5.5 1.5 1.1 0 .4-.3.7-.7.8", "M15.5 4.5c2.9 0 5 1.8 5.2 4.3", "M15.8 7.2c1.5 0 2.7.9 2.8 2.3"] },
{ key: "douyin", label: "抖音", paths: ["M5 17.5a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M11 17.5V4.5l7 2v3.2l-4.6-1.3"] },
{ key: "bilibili", label: "B站", paths: ["M6 6.5h12a3 3 0 0 1 3 3v6.5a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-6.5a3 3 0 0 1 3-3z", "M7 4l2.2 2.4M17 4l-2.2 2.4", "M9.2 10.5v4M14.8 10.5v4"] },
{ key: "lianjie", label: "链接", paths: ["m9.5 14.5 5-5", "m8 12-3 3a4 4 0 0 0 5.7 5.7l3-3", "m16 12 3-3a4 4 0 0 0-5.7-5.7l-3 3"] },
{ key: "dangan", label: "档案", paths: ["M6 3.5h8.5L18 7v13.5H6z", "M14 3.5V7h4", "M9 11h6M9 14.5h6M9 18h3.5"] },
{ key: "xiaoli", label: "校历/校史", paths: ["M4 21V10l8-6.2L20 10v11", "M10 21v-6h4v6", "M4 21h16", "M9.5 10.5h5"] },
{ key: "tiyu", label: "体育", paths: ["M8 4.5h8v5a4 4 0 0 1-8 0z", "M8 5.5H5v1.6A3.4 3.4 0 0 0 8.4 10M16 5.5h3v1.6A3.4 3.4 0 0 1 15.6 10", "M12 13.5v3M8.5 20h7M9.8 20c0-1.7.9-3.5 2.2-3.5s2.2 1.8 2.2 3.5"] },
{ key: "yishu", label: "艺术", paths: ["M12 3a9 9 0 0 0 0 18c1.4 0 2-.8 2-1.7 0-1.5-1.6-1.9-1.6-3.1 0-1 .8-1.7 2-1.7h1.8A4.8 4.8 0 0 0 21 9.7C21 5.9 17 3 12 3z", "M6.9 10.5a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z", "M11.1 7.6a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z", "M15.3 10a.9.9 0 1 0 1.8 0 .9.9 0 0 0-1.8 0z"] },
{ key: "shiyan", label: "实验", paths: ["M10 3.5h4M11 3.5v6l-4.6 8.2A2 2 0 0 0 8.2 20.5h7.6a2 2 0 0 0 1.8-2.8L13 9.5v-6", "M8.5 14.5h7"] },
];
export function iconLabelOf(key: string | undefined | null): string {
return ICON_DEFS.find(i => i.key === key)?.label || "";
}
+4
View File
@@ -1,5 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// import.meta.env 类型声明:VITE_* 仅供本地开发/演示(.env.dev、.env.mock,vite dev 对应 mode)读取;
// build 不读任何 .env.*(.env.base/.env.prod 已删),VITE_* 恒为 undefined,不会进构建产物。
// 线上环境差异由 env-config 运行时配置承担(index.html 引导脚本 → window.__APP_CONFIG__,见
// src/assets/config/host.config.ts)。
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_BASE_HOST: string; readonly VITE_BASE_HOST: string;
readonly VITE_SSO_HOST: string; readonly VITE_SSO_HOST: string;
+3 -1
View File
@@ -11,7 +11,9 @@ import { cmsMockPlugin } from "./mock";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ command, mode }) => { export default defineConfig(({ command, mode }) => {
const outDir = command === "build" ? "dist/base" : "dist"; // 构建产物直出 dist/(与 UserBase 同风格):zip 打包/static/admin 拷贝都取 dist 根,
// 不再有 dist/base 中间层;dev(serve) 不写产物,两者共用该值无冲突
const outDir = "dist";
// VITE_IS_MOCK=true 时启用本地 mock 中间件(拦截 /api、/portal 等,无需后端) // VITE_IS_MOCK=true 时启用本地 mock 中间件(拦截 /api、/portal 等,无需后端)
const mockEnabled = loadEnv(mode, process.cwd(), "VITE_").VITE_IS_MOCK === "true"; const mockEnabled = loadEnv(mode, process.cwd(), "VITE_").VITE_IS_MOCK === "true";
return { return {