Compare commits

...
35 Commits
Author SHA1 Message Date
ops fdb9f5109f ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing 5a080f502d style(一周安排小组件): 选中态上下让出间距、时间列移到信息行最右端、标题改 14px
build / build (push) Successful in 46s
① 选中态不再「挤」:星期数 <span> 加 padding-bottom:3px(数字位置不动,把今日方块与
   圆点整体让下去)+ 圆点 bottom -8→-11px(今日格 -5.5→-8.5px,七格仍同线)
   ⇒ 星期数↔方块 4.1→6.6px、方块↔圆点 1.5→4.5px,七日条 63→66px
   ⚠️ 用 padding-bottom 而非 margin-bottom:后者会与 b 的 margin-top 折叠
   (max(正)+min(负)),今日格 −1px 与其余格 2px 走不同结果 ⇒ 今日数字偏低 1.75px
② 时间列移到信息行最右端:列表 DOM 由 .line[标题+时间] 改为
   content(独占一行) + .meta[.meta-txt + .time];.meta 用 flex space-between、
   .time flex:0 0 auto;无时间不留位。标题可用宽度 232→314px(带时间那条)
③ 标题字号 15→14px(信息行 12.5px 不变)
④ 结构化骨架同步:星期条占位改 24px 外框(21 行盒 + 3 下留白)+ 内层 ::before 画 10px 条;
   ⚠️ 内层定位用父级 padding-top,不用子元素 margin-top(会塌出父级多 5.5px)

harness 真组件复测:四态 367.58/367.59/367.58/367.58(差 0.01px);今日墨迹中心 87 vs
其余 87.25(0.25px);七格圆点中心全 107.5;.time 父级=meta、content 14px/block。
2026-10-16 15:27:26 +08:00
chenxing 04c12e2076 style(一周安排小组件): 箭头加 hover 药丸、今日方块 28px 同基线、加载骨架结构化三态同高、查看概览移卡头直跳
build / build (push) Successful in 44s
① 翻页箭头:定尺寸 24×24 inline-flex 双向居中 + 圆角 4 + 过渡;
   hover 灰底药丸(#eef1f5/#344449),active 加深一档,禁用态不响应
② 今日方块:height 28px + line-height 28px,margin-top 由 5px 改 −1px
   (28px 行盒使数字中心下移 5.5px,−0.75 取整补回,与其余 6 天同基线);
   圆点改锚数字块(b::after bottom:-8px),今日格圆点 bottom:-5.5px 补位,七格圆点同线
③ 加载态:通用 4 行 Skeleton(158px)换成结构化骨架(标题 40 + 七日条 63 + 列表 171
   + 备注区 66.59),与内容逐段同构同高;备注区恒占位(min-height 58.59px,无备注只留白)
   ⇒ 加载/空周/有备注周三态恒等 364.6px,切换零高度跳变(方案 B)
④ 删除「日常详情」标题行;「查看概览」改 a 标签直跳(target=_blank),
   Teleport 进卡头右侧 #wg-hdr-<id> 锚点(与课程表班级切换器同机制),
   样式复刻 .cls-trigger 一族;index.vue 不用改

harness 真组件复测:三态 364.58/364.58/364.59;今日墨迹中心 84 vs 其余 84.25(差 0.25px);
七格圆点中心全 101.5;箭头药丸 24×24 双盒一致;ov-link 落位卡头右区、关闭按钮左侧 6px。
2026-10-16 14:38:25 +08:00
chenxing 84bbf2faf4 style(一周安排小组件): 节假日去红同周末灰、备注「收起」贴末行右端、汉字时间降一档、今日方块数字改 flex 居中
build / build (push) Successful in 43s
- ① 节假日日期不再标红:#e64545 撤除,与周末统一 #98a2b0(.d.rest b, .d.holiday b)
- ② 备注「收起」不再另起一行:.rk-more 由「仅折叠态」改恒绝对定位(right:10px / bottom:8px),
  .rk-txt 恒 padding-right:34px 给按钮让位;移除已无用的 collapsed 类
  实测:按钮落在最后一行区间内、超出正文底边 0px、右端 gap 0px、正文仍 4 行未多占一行
- ③ times 含汉字时降一档:新增 isCjk(/[\u4e00-\u9fa5]/) + .time.is-cjk{font-size:13px}
  依据:同字号下汉字字面高 18px vs 数字 14px(截图像素实测),「全天」与「14:00-15:00」观感拉平
- ④ 今日方块数字居中:.d.is-on b 改 display:flex + align/justify:center + height:22px + line-height:1
  原 1.5 行盒含 descender 空白 ⇒ 数字视觉偏上 1.2px;修后偏 +0.5px(渲染量化噪声底),
  且与其余 6 天墨迹行完全一致(64..85,差 0.00px);水平方向经「圆点反推格中心」校验方块为精确居中,
  残余偏差来自字体字怀(「1」开头的数字墨迹心普遍右偏 0.2~1.0px),故不加固定位移
2026-10-16 10:19:44 +08:00
chenxing 5d7b8526f4 fix(一周安排小组件): 面板关横向滚动(修外框 X 轴滚动条、日期条被裁)+ 色系 #5979ea、列表区固定 3 条、信息行全空落「-」、备注无则不渲染
build / build (push) Successful in 43s
- 色系:#2d8cf0 / #1d4ed8 整族换 #5979ea(hover #4864d8),覆盖今日方块、圆点、查看概览、展开、今日星期
- 列表区:268px(≈5 条) → calc(var(--wk-row) * 3) = 171px(正好 3 条 + 滚动),新增 --wk-row: 57px
- 条目等高 57px;信息行(地点 · 负责人 · 出席对象)恒渲染,三者全空落「-」,不再留白
- 备注:无备注整块不渲染;超 2 行才出「展开」且贴第二行末尾;remarkText 加 trim;换周重置 remarkOverflow
- 「查看概览 ›」→「查看概览」(去掉箭头字符)
- 修 X 轴滚动条根因(views/index.vue):widgetOptions 的 scrollPanel 关掉 scrollingX
  原 scrollingX=true 时 vuescroll 给 .__view 内联 width:fit-content,包含块被最长文本撑到 ≈425px
  ⇒ 面板 scrollWidth 561 > clientWidth 391,外框出 #333 横向条、日期条 7 列被裁成 5 列、长条目省略号失效
  同时 .content { min-width: 0 } 兜底(flex 项默认 min-width:auto 会让 nowrap 文本不收缩)
2026-10-16 09:50:39 +08:00
chenxing 651e7e16f3 style(一周安排小组件): 列表区固定5条高度+滚动/空态等高、字号对齐旧版、备注与数据区分
build / build (push) Successful in 48s
- 列表区 .wk-list-area 固定 268px(旧版实测行距≈54px×5):超出滚动、空态同高,切「休/节假日」卡片高度不再跳(实测三态卡片高 554px 完全一致)
- 日期条圆点改绝对定位+常驻占位(visibility):修「整周无安排时日期条矮 6px」;今日蓝底方块不再把圆点盖成同色
- 字号对齐旧版:标题 14→15、星期 12→14、日期 13→15、日常详情 14→16、查看概览 12→13、列表内容/时间 12.5→15、meta 11.5→12.5、备注 12→13、空态 13→14
- 备注与安排数据区分:上分割线 + #f7f8fa 浅底块 + 6px 圆角
2026-10-15 21:25:31 +08:00
chenxing 8af636641f feat(一周安排小组件): 标题/日期精简 + 日常详情加重 + 外跳「查看概览」+ 备注两行折叠
build / build (push) Successful in 2m44s
- 标题改合成简称「10月12日~10月18日 第N周」,接口全称留 hover(title 属性)
- 日期条只留「日」(10.12→12),今日改蓝底白字方块(原浅蓝底)
- 「日常详情」12px/400/#6e7c87 → 14px/700/#1f2329,同排右侧新增「查看概览」外跳(新窗口)
- 外跳地址由后端下发(props.overviewUrl);缺配置则不渲染入口
- 备注 line-clamp:2 + 溢出才出「展开/收起」,支持含换行的多行文本
- index.vue 透传 overview-url;WidgetEditModal 配置页加「外跳入口」行,超管保存/校管覆盖/清除覆盖三链路带上
2026-10-15 20:26:07 +08:00
chenxing 8305129c73 feat(一周安排小组件): 对接 workplan oapi 单周对象 + 周偏移翻页
build / build (push) Successful in 50s
- 直吃 oapi data 单周对象(title/year/term/week/monday/days[7]/remark/hasData/minWeek/maxWeek)
- 箭头翻页按 minWeek/maxWeek 门控,非本周默认落周一,本周按今日定位
- 空周门控 hasWeek(title 非空)替代 hasData:空周保留标题行+箭头+七日条,不再翻进去出不来
- 修复形参 week 遮蔽 ref 致 loading 永驻骨架(ref 改名 weekData,loading/reportHeight 移入 finally)
- 大图标空态改模板内联 SVG
2026-10-15 16:30:13 +08:00
chenxing 57cb98f49e fix(小组件): 空态仪表盘改用纯矢量图标 —— 原 widget-empty.svg 是内嵌 PNG 的假 SVG,currentColor/color 对其无效;按 alpha 轮廓亚像素反推(9 轮廓/245 顶点,IoU 98.2%)
build / build (push) Successful in 46s
2026-10-14 11:38:25 +08:00
chenxing c7a295521d style(小组件): 「+」添加小组件下拉改为与卡片同族 —— 复用玻璃令牌,浮层底色加深一档,白字/白描边/大投影
build / build (push) Successful in 45s
2026-10-14 10:30:36 +08:00
chenxing 2f4ee76b7b style(小组件): 「+」添加小组件下拉去雾做锐 —— 1px 实描边接管边界、投影收紧减淡
build / build (push) Successful in 43s
- Dropdown 加 transfer-class-name="wg-dd",把皮肤严格限定在这一个下拉上
  (库的 dropdownCls 会把该类挂到面板 .ivu-select-dropdown,不影响全站其他下拉/选择器)
- 面板:border 1px solid rgba(15,23,42,.08) 接管边界;box-shadow 由库默认的
  0 1px 6px rgba(0,0,0,.2) 收紧减淡为 0 2px 8px rgba(15,23,42,.12);radius 4->6;padding 5px 0 -> 4px 0
- 白底、文字色(#515a6e)、item hover(#f3f3f3) 一律不动;卡片玻璃效果不动
- 选择器用 .ivu-select-dropdown.wg-dd(0,2,0)稳压库默认 .ivu-select-dropdown(0,1,0)
2026-10-14 09:49:22 +08:00
chenxing 27ce68a76e style(课表小组件): 下拉选中态改品牌色(#5979ea/#eef2ff);表格限高 352px 只露 7 节、超出滚动、表头 sticky 常驻
build / build (push) Successful in 41s
- .cls-item.on 由 #2d8cf0/#f0f7ff 改为 var(--color-primary, #5979ea) / var(--color-primary-weak, #eef2ff),
  与 WsToastLayer.vue 既有约定一致(CSS 变量 + 回退值);hover 改 #f4f7ff
- 表格外套 .tt-scroll:max-height 352px(表头 44 + 7×44)+ overflow-y auto + 6px 细滚动条
- th 加 position sticky / top 0 / background #fff;因 sticky + border-collapse 会丢 cell border,
  分隔线由 border-bottom 改为 box-shadow inset 0 -1px 0 #e9edf1
2026-10-14 09:08:06 +08:00
chenxing 3a7502f106 style(课表小组件): 表头垂直居中、左右留白 8px、行高 44px、科目 2 行省略号兜底
build / build (push) Successful in 46s
- th 补 vertical-align: middle —— base/_reset.scss(meyerweb) 给 th/td 统一设了 baseline,
  scoped 只给 td 写了 middle 导致表头文字按基线对齐、偏上,视觉上像"贴住卡片 header"
- .table-wrap padding 2px 15px 12px → 8px 8px 10px(小组件窄,收敛左右留白)
- td height 52 → 44px
- 科目包 <span class="sub" :title>,-webkit-line-clamp:2:超长科目最多 2 行、省略号收尾、悬停看全文
2026-10-14 08:44:27 +08:00
chenxing 479f7b22a7 fix(课表小组件): 三项修复——header 显示「年级+班级」、body 去班级行、空态改内联 SVG、表格换私有类名去掉全局竖线
build / build (push) Successful in 3m52s
- header 与下拉项显示 gradeName + className(「高一 1班」)
- body 单元格只显示科目,不再重复班级
- 空态由「CSS 背景图 + 空 div」改为模板内联 SVG + 「暂无课表数据」
- 表格类名 my-table → tt:避开全局 componets/_my_table.scss 带进的 #c7c7c7 竖线与 th #fafafa 灰底
- 表头去灰底并加 1px 分隔线(#e9edf1),行间横线加深 #eef1f4,表头字色 #344449
2026-10-13 21:02:41 +08:00
chenxing 0baba07707 fix(小组件): 内容卡补回不透明白底(#fff)——旧版由iframe自带,内置化后丢失导致整卡糊成一色
build / build (push) Failing after 44s
2026-10-13 16:52:06 +08:00
chenxing 6f4b3f07d4 feat(课表小组件): SyllabusWidget改版直吃AcaSyllabu oapi结构——班级下拉切换器Teleport进卡头(通用header插槽能力),行=slotNo中文数字、列=周一~周五(周末有课才追加)、单元格=班级/科目;空态换原版no-data-box.svg插画;卡头钉死#f7f8f9+overflow visible供浮层溢出
build / build (push) Successful in 1m38s
2026-10-13 12:27:41 +08:00
chenxing a3ddb90a8a fix(小组件): 空态图标按场景分家 + 桌面面板拖拽态翻白
build / build (push) Successful in 42s
- 管理台空态改用新线性图标 widget-empty-line.svg(虚线框+加号,currentColor 可着色)
- 桌面小工具面板空态还原原版 widget-empty.svg(内嵌灰度仪表盘 PNG,4279f99 版)
- _widget.scss 拖拽态翻白:背景/边框/阴影与前景令牌一并反转,避免重演「白底压白字」
2026-10-12 08:47:45 +08:00
chenxing 5a19a4ed79 feat(小组件管理改版): 卡片网格管理台 + 校级覆盖层 + 退役系统设置/应用管理
build / build (push) Successful in 42s
后端
- widget_catalog 加 3 列:scope_type(三态可见范围)/ role_types(可见角色)/ role_alias(角色专属命名 JSON)
- 新增两表:widget_catalog_school(点名表,uk_widget_school)、widget_school_setting(校级覆盖层,uk_widget_sch)
- WidgetController 新增 GetWidgetCatalogList / SaveWidgetCatalog / DeleteWidgetCatalog /
  GetWidgetSchoolSetting / SaveWidgetSchoolSetting;权限拆 checkSuperAdmin(仅超管)与
  checkSchAdmin(超管或 roleId∈{2,5,7})
- SaveWidgetSchoolSetting 强制 schId 取当前登录校,前端传参不采信(防越权)
- 显示名三级链:校级别名 > 角色专属命名 > 组件名;role_alias 坏 JSON 降级不抛
- 启动期幂等 seeding(空库才写 3 条内置目录行)
- 手工 DDL 脚本 resources/sql/widget_catalog_v2.sql(含回滚)

前端
- WidgetManage 重写为卡片网格:超管/校管双视角、类型页签 + 搜索、拖拽排序、启停、
  删除、登记内置组件、空态引导
- 新增 WidgetCard / WidgetEditModal / WidgetRegisterModal
- UserSettingApi 加 6 个方法;types 加 WidgetCatalog 接口
- 管理端列表整行回传(SaveWidgetCatalog 为整行 merge)

退役
- 删除 SysSetting.vue / SysTempManage.vue / AppSettingApi.ts 与后端 AppSettingController /
  AppCategory(SQL) 全链路,清理路由、Settings 导航与样式分支
- 保留 SysSettingApi.ts(壁纸/logo 池管理仍在使用)
2026-10-11 21:40:31 +08:00
chenxing 9980ea1264 外观SDK化A:sync 件跟版 20261011-1332-246e1e3(侧栏默认翻转 sideDeep→false)
build / build (push) Successful in 2m50s
2026-10-11 13:35:12 +08:00
chenxing f5faa29018 feat(小组件内置化): 面板三分支渲染 + 三个内置组件 + 自定义片段 srcdoc + 玻璃层对比度
build / build (push) Successful in 42s
- components/widgets:widgetRegistry(defineAsyncComponent 按需加载)+ EvaluateWidget/SyllabusWidget/WeekplanWidget,props widgetId、emit loaded、内部自行取数、ResizeObserver 自报真实高度;呈现只保留语义不逐像素复刻旧 html
- views/index.vue:iframe 单分支改三分支(内置组件 / compType=2 走 :srcdoc + sandbox="allow-scripts allow-same-origin allow-forms"(不给 allow-top-navigation)/ 兜底「该小工具暂不可用」不白屏);:src="item.url" 与候选池 url 依赖删除;postMessage 身份下发只服务片段分支,高度统一收口 find.height
- utils/widgetFragment.ts:wrapFragment 两段注入——取数桥在 head(早于片段自身脚本,否则 yhWidget undefined)、高度上报在 body 之后;</script> 拆拼防提前闭合门户脚本;节流用 setTimeout 尾沿 + 300/1200ms 兜底(iframe 隐藏期子文档不出动画帧,rAF 会把上报永久吞掉、帧高卡 min-height 撑出内层滚动);桥 URL 走 serviceHost 且自行解平台信封 data
- views/Settings.vue:新增 admin pane「widget」(ADMIN_PANES/ALL_PANES/「系统」组导航项/v-else-if 链分支),普通用户导航隐藏且 URL 直达落 no-perm 黄条
- views/widget/WidgetManage.vue:双卡管理页(内置/自定义分组列表 + 名称/键/类型/data_code/排序/启用/sch_id 表单 + CodeMirror + 上传 .html 走 FileReader 填编辑器,不落地文件不产生 URL;内置条目 content 只读)
- _widget.scss:--wg-* 六令牌 + 深玻璃 blur(18px) saturate(140%) + 描边 + 投影;拖拽态加深 blur(22px) 不再翻白;空态与 +/− 前景改走 token(配套 widget-add/sub/empty.svg 由硬编码色改 currentColor,widget-empty 原为内嵌 PNG 无法着色故换线性版);.widget-item 浅底深字保持不动
- UserSettingApi.ts:getWidgetData / getWidgetContent / GetWidgetCatalogList / SaveWidgetCatalog
- 另含 src/store/index.ts 的 getWidgetAppIds 未配置态 .reverse()(非本会话所写,按用户确认一并提交:与 getWidgetAppList 降序比较器对齐,使未配置态按 widget_catalog.sort 正序渲染)
2026-10-11 08:50:49 +08:00
chenxing be0dac6bfa chore(theme): 版本戳跟版 20261009-1655-488a3c3(YschTheme skin 16px 定稿)
build / build (push) Successful in 44s
2026-10-09 17:08:10 +08:00
chenxing 1b812bc9c9 fix(切换身份联动): 消息角标/消息页/设置中心随身份切换自动重拉——Header 摘要挂 switchRole、msg 页与 Settings pane watch curRoleUid(桌面/应用中心既有机制不变),无需手动刷新
build / build (push) Successful in 41s
2026-10-09 15:33:26 +08:00
chenxing ac67215c78 feat(header): WS 实时推送卡片落地 design/03 .ws-toast——类型色条+图标章+进度条 8s 自动消退+点击进 /msg,替代 Notice.info 临时卡片(审批直批按钮本期不做)
build / build (push) Successful in 1m32s
2026-10-09 15:06:23 +08:00
chenxing 0621ef1ede fix(壁纸): 删除当前生效壁纸后重拉用户设置即时回落兜底(设置 pane 与 Header 模态框两处删除入口) 2026-10-09 14:53:19 +08:00
chenxing 0264d41428 fix(设置): LOGO 池预览改正方形缩略(LOGO文字与主题JSON化增补)
build / build (push) Successful in 47s
2026-10-09 12:29:32 +08:00
chenxing d1a371d257 feat(header): 铃铛新消息按当前身份学校过滤(getCurSchId 口径,平台级/超管放行)——消息按学校隔离 批次4前端 2026-10-09 12:23:17 +08:00
chenxing e7f70f46a8 fix(门户+设置): LOGO 文字贴徽标左对齐并放大字号(修 space-between 居中);壁纸 pane 管理员选本级默认(LOGO文字与主题JSON化增补)
build / build (push) Successful in 44s
2026-10-09 11:54:17 +08:00
chenxing b774d2e733 chore(theme): theme.ts 同步通道 1038 版本戳(LOGO文字与主题JSON化)
build / build (push) Successful in 41s
2026-10-09 10:49:37 +08:00
chenxing 2819f96dfd feat(设置+门户): LOGO 文字配置(校/平台两级,pane 内维护)+ header 徽标旁显示;个人主题保存改 JSON 形态(LOGO文字与主题JSON化)
build / build (push) Successful in 1m27s
2026-10-09 10:35:25 +08:00
chenxing bdc8898b93 chore(theme): YschTheme sync 件更新(版本 20261009-0054-5732ee2,信封兼容)
build / build (push) Successful in 43s
2026-10-09 01:06:15 +08:00
chenxing 49f15b8a05 feat(settings): 设置中心三级重构(一层一角色)+ 审计缺陷修复
build / build (push) Successful in 44s
三级重构(S1-S6,方案见 docs/设置中心三级重构-执行方案-20261008.md):
- Settings.vue 三层化主体:curLayer=my/school/platform 单 pane 分层,主题/头像/壁纸/桌面/LOGO 按身份只渲染本级
- 桌面应用三级默认 pane(used_app SCHOOL/BASE 读写 + 角色分段 + ≤8 上限)+ 统计设置真身迁入(StatsBarModal 退役)
- SysSetting.vue 去基础设置/机构树;ThemePreview 预览壳套 .layout-wrap; UsedAppApi 新增
审计缺陷修复(设置中心三级重构与消息搜索-审计报告-20261008):
- 【P0】DESKTOP_ROLE_TYPES 角色 1/2 标签对齐 SysRoleEnum(1教师/2校领导/3学生),桌面默认不再张冠李戴
- 命令面板:Enter/↑↓ 加 isComposing 守卫防中文输入法误触;过滤后高亮索引归零;stagger 延迟 --i 封顶 20
- WS 通知标题移除 frame.appKey 死消费;BaseApi summary todos 类型同步去 appKey
2026-10-08 23:08:16 +08:00
chenxing 2bd7ef3b80 refactor(header): 命令面板去背景联动,结果右侧标注应用类型
build / build (push) Successful in 1m21s
用户三点修订:
1. 面板已是模态框,去掉背景应用列表的同步过滤(解开 mittBus.searchChange 整条链)
2. 结果右侧灰色小字显示应用类型(来源 BoaAppType.name,与 /apps 分组标题同源)
3. 类型呈现暂按方案 A(右侧灰字);方案 B(类型分组)对照稿见 design/07

- Header.vue: watch(searchAppText) 不再 emit("searchChange");cmdkResult 由
  getFlattenAppList(纯 flatten,会丢类型名)改为遍历 curUserAppList 分组展开并挂
  appTypeName;模板加 .app-cmdk-type
- AppList.vue: 删 searchAppText / searchAppList / isSearch 与模板搜索态分支、
  .no-search-box 样式、孤儿导入 flatten(lodash) 与 requireImg;删 mittBus 监听
- utils/mitt.ts: 删 searchChange 事件类型
- assets/styles/header.scss: 加 .app-cmdk-type 样式
- harness-main.ts: mock curUserAppList 补齐 BoaAppType 分组结构(id + name),
  原 mock 丢了 name 导致类型名取不到

注:本仓工作区另有并行会话 WIP(StatsBarModal 迁入 Settings / 主题预览),
本提交只含本案 5 个文件与 hunks,未触碰对方改动。
2026-10-08 19:10:14 +08:00
chenxing 4f36ff9ceb 外观偏好异步补齐D:设置页选择端双写服务端真源(fire-and-forget,本地体验不变)
- BaseApi 加三方法:getAppearance / saveMyTheme(紧凑编码,null=跟随)/ saveMyAvatarStyle
- Settings.vue 五个写键分支旁挂双写:pickMyTheme / followSchoolTheme(置 NULL) / pickMyAvatarStyle / followSchoolAvatar(置 NULL) / pickSide(写 yh-theme 的全部分支)
- 双写失败 console.warn 不弹错不回滚本地(本地体验与改造前逐字节一致)
- App.vue loadSchThemeDefault/loadSchAvatarDefault 保留不动(与 init 异步补齐幂等收敛)
- sync 件:UserAvatar.vue 监听 yh-appearance-updated + theme.ts 版本戳 20261008-1831-d9db725
- 注:本仓工作区另有并行会话 WIP(harness/命令面板),本提交只含本案文件与 hunks
2026-10-08 18:58:30 +08:00
chenxing b2307f583f chore(harness): 补齐全局脚本引入 + 命令面板取证钩子
build / build (push) Successful in 42s
- harness.html 照抄 index.html:30-34 的 ztree.css / jquery / lodash / jquery.ztree:
  main.ts 不 import 这几个全局脚本,靠 html 提供;缺 jquery 会稳定报
  "Uncaught ReferenceError: $ is not defined"(本项目既有坑)
- harness-main.ts 新增 /apps 空壳路由(只需 route.name === "apps" 让 Header 渲染放大镜,
  不挂真实 AppList 以免本机无后端时报错污染控制台)与 ?cmd=1[&q=xx] 取证钩子
  (自动点开放大镜并可选输入关键词)
2026-10-08 17:45:33 +08:00
chenxing ec696d5187 feat(header): 搜索框改居中命令面板,应用列表数据与 loading 与 AppList 共享
- header.scss: 删旧 .search-box 与 fade1/search-animation/icon-animation 动画段,
  新增 app-cmdk-* 面板样式。样式段必须放顶层——面板 Teleport 到 body,嵌进
  .default-header 会生成后代选择器而匹配不到
- Header.vue: 搜索 li 收敛为单图标 + openSearch;模板末尾追加 <Teleport to="body">
  命令面板,支持实时过滤 / ↑↓ 键盘导航 / Enter 打开 / Esc 关闭 / 空结果态;
  删 watch(isSearching) 与空函数 showSeachBox;挂/卸 window keydown
- store/index.ts: 新增 appListLoading 字段与 setAppListLoading
- utils/bschLinkPage.ts: 抽出 openAppPage(app, ifameAppIdArr),与点击应用图标同一路径
- AppList.vue: reloadAppData 写 store(数据 + loading,try/finally);
  linkPage(item) 委托 openAppPage

硬约束保持不动:watch(searchAppText) -> mittBus.emit("searchChange")(背景应用网格靠它过滤)、
isSearchIconShow(route.name === "apps")。
2026-10-08 17:45:25 +08:00
43 changed files with 6366 additions and 2276 deletions
+4 -1
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net
steps:
- 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 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
+7
View File
@@ -19,6 +19,13 @@ card-harness.html
card-harness.vite.config.ts
src/card-harness-main.ts
# 小组件内置化取证 harness(同一类,本地临时)
dist_widget_harness
widget-harness.html
widget-harness.vite.config.ts
src/widget-harness-main.ts
.tmp-build
yarn.lock
.npmrc
# Editor directories and files
+6
View File
@@ -5,6 +5,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>harness-取证</title>
<!-- 照抄 index.html:30-34:main.ts 不 import 这几个全局脚本,靠 html 提供。
缺 jquery ⇒ "$ is not defined" ⇒ v-wave / vuescroll 抛错(本项目固有坑) -->
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
<script src="./libs/lodash.min.js"></script>
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
</head>
<body>
-63
View File
@@ -1,63 +0,0 @@
import Abstract from "@/utils/axios/abstract";
import { PageData, PageParams } from "@/types/Basics";
const path = "/api";
export interface AppCategoryItem {
id?: number;
name: string;
apiUrl: string;
dataKey: string;
paramsKey: string;
roleType: number;
cId?: number;
cDate?: string;
resDate?: string;
}
/** 系统设置-应用管理(应用类别 CRUD + 模板管理) */
class AppSettingApi extends Abstract {
/** 应用分页列表 */
getAppCategoryList(params: PageParams & { roleType: number; name?: string }) {
const url = path + "/GetAppCategoryList";
return this.getRequest<PageData>({ url, params });
}
/** 保存应用(新增/编辑) */
saveAppCategory(data: AppCategoryItem) {
const url = path + "/SaveAppCategory";
return this.postRequest({ url, data });
}
/** 删除应用 */
deleteAppCategory(id: number) {
const url = path + "/DelAppCategory";
return this.postRequest({ url, params: { id } });
}
/** 查询应用详情 */
findAppCategory(id: number) {
const url = path + "/FindAppCategory";
return this.getRequest<AppCategoryItem>({ url, params: { id } });
}
/** 刷新资源时间戳(上传模板后调用) */
updateResDate(id: number) {
const url = path + "/UpdateResDate";
return this.postRequest({ url, params: { id } });
}
/** 模板文件列表 */
getTempList(appModId: number) {
const url = path + "/GetTempList";
return this.getRequest<any[]>({ url, params: { appModId } });
}
/** 保存模板文件内容(JSON body,避免大文件内容超出 URL/请求头长度限制) */
saveTempFile(appModId: number, fileName: string, content: string) {
const url = path + "/SaveTempFile";
return this.postRequest({ url, data: { appModId, fileName, content } });
}
}
export default new AppSettingApi();
+58 -1
View File
@@ -125,7 +125,7 @@ class Base extends Abstract {
const url = path + "/msgCenter/summary";
return this.getRequest<{
unreadCount: number;
todos: { appKey: string; count: number }[];
todos: { count: number }[];
sysUnread?: number;
bizUnread?: number;
approvalUnread?: number;
@@ -226,6 +226,34 @@ class Base extends Abstract {
return this.postRequest({ url });
}
// ===== LOGO 文字(平台/学校名称,2026-10-09 LOGO 文字轮;后端契约见 SchSettingsController#schLogoText)=====
/** 读 LOGO 文字(已登录可读):{logoText: string|null, schId=实际命中层}——本校空 → 平台层 → null */
getSchLogoText() {
const url = path + "/schLogoText";
return this.getRequest<{ logoText: string | null; schId: number }>({ url });
}
/** 保存 LOGO 文字(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校),≤64;空串=清空本级 */
saveSchLogoText(logoText: string) {
const url = path + "/saveSchLogoText";
return this.postRequest({ url, params: { logoText } });
}
// ===== 默认壁纸(平台/学校两级,壁纸三级默认轮 2026-10-09;后端契约见 SchSettingsController#schWallpaper)=====
/** 读默认壁纸(已登录可读):{wallpaperUrl: string|null, schId=实际命中层}——本校空 → 平台层 → null */
getSchWallpaper() {
const url = path + "/schWallpaper";
return this.getRequest<{ wallpaperUrl: string | null; schId: number }>({ url });
}
/** 保存默认壁纸(仅学校管理员/超管):URL 取管理池返回的绝对地址原样存,≤255;空串=清空本级 */
saveSchWallpaper(wallpaperUrl: string) {
const url = path + "/saveSchWallpaper";
return this.postRequest({ url, params: { wallpaperUrl } });
}
// ===== 学校默认头像样式(默认头像轮 2026-09-30;后端契约见 SchSettingsController#schAvatarStyle)=====
/**
@@ -249,6 +277,35 @@ class Base extends Abstract {
return this.postRequest({ url });
}
// ===== 用户外观偏好(外观偏好服务端化 2026-10-08;后端契约见 AppearanceController)=====
/**
* 读当前用户外观偏好聚合:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}(紧凑编码/绝对 URL,查不到为 null)。
* 各子应用 yh-theme-init 异步补齐段的同源数据源;选择端预留(本轮设置页双写用不到)。
*/
getAppearance() {
const url = path + "/appearance";
return this.getRequest<{
myTheme: string | null;
schTheme: string | null;
avatarMy: string | null;
avatarSch: string | null;
logoUrl: string | null;
}>({ url });
}
/** 保存用户自选主题档(紧凑编码 theme|sideDeep01|gradOn01|sideFollow01);null=跟随学校默认。设置页 fire-and-forget 双写:成功不提示、失败不回滚本地 */
saveMyTheme(themeState: string | null) {
const url = path + "/saveMyTheme";
return this.postRequest({ url, data: { themeState } as any });
}
/** 保存用户自选头像样式("text"|"silhouette");null=跟随学校默认。双写语义同 saveMyTheme */
saveMyAvatarStyle(avatarStyle: string | null) {
const url = path + "/saveMyAvatarStyle";
return this.postRequest({ url, data: { avatarStyle } as any });
}
savUserRole(info: any) {
const url = path + "/user/savUserRole";
return this.postRequest({ url, data: info as any });
+1 -8
View File
@@ -1,16 +1,9 @@
import Abstract from "@/utils/axios/abstract";
import { PageData, PageParams } from "@/types/Basics";
const path = "/api";
/** 系统设置-基础设置(机构树 / 壁纸 / Logo 管理视图) */
/** 门户外观管理视图(壁纸 / Logo 池;S4.7 后由设置中心「门户外观」pane 调用,机构树随「系统设置-基础设置」一起移除) */
class SysSettingApi extends Abstract {
/** 机构树(仅超管),已裁剪到学校层 */
getSchTree() {
const url = path + "/getSchTree";
return this.getRequest<any>({ url });
}
/** 壁纸管理列表(系统 + 目标学校,不含个人壁纸) */
getSchWallpaperManageList(schId?: number) {
const url = path + "/getSchWallpaperManageList";
+33
View File
@@ -0,0 +1,33 @@
import Abstract from "@/utils/axios/abstract";
const path = "/api";
/**
* 桌面/常用应用三级默认(used_app:BASE 平台 / SCHOOL 本校 / USER 个人)。
* 后端 UsedAppController + UsedAppServiceImpl.findById 已实现「用户 > 学校 > 系统」级联与覆盖式保存,
* 本封装只服务设置中心「桌面应用」pane(S6),不改后端接口签名。
*/
class UsedAppApi extends Abstract {
/**
* 取某角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。
* @param roleType 角色(1 校管理员 / 2 教师 / 3 学生)
* @param schId 学校ID;0 时后端按登录人主学校→归属组推断
* @param isSch 学校级读取:true 且非超管时后端令 uid=schId,于是只回本校 SCHOOL 那一行;
* 超管传 true 也会被后端强制回 false(走级联,实际取到 BASE 平台行)
*/
getUsedApp(roleType: number, schId: number, isSch: boolean) {
const url = path + "/usedapp/getUsedApp";
return this.getRequest<any[]>({ url, params: { roleType, schId, isSch } });
}
/**
* 覆盖式保存某角色本级默认桌面(同 setId+type+roleType+schId 存在则更新)。
* 数量上限 ≤8 由前端控制(后端注释口径);超管传任何 type 都会被后端强制为 BASE(0)。
*/
saveUsedApp(params: { appIds: string; type: number; roleType: number; schId?: number }) {
const url = path + "/usedapp/saveUsedApp";
return this.postRequest({ url, data: params });
}
}
export default new UsedAppApi();
+48 -1
View File
@@ -1,4 +1,4 @@
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetPositionResp } from "@/types";
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetCatalog, WidgetPositionResp } from "@/types";
import { BaseDict, PageData, PageParams } from "@/types/Basics";
import Abstract from "@/utils/axios/abstract";
@@ -92,6 +92,53 @@ class UserSettings extends Abstract {
const url = path + "/sortUserDesktopApp";
return this.postRequest<any>({ url, data: { ids: ids.join(",") } });
}
/** 小组件统一取数:params 为 urlencoded 查询串(对齐旧 otherParams 口径) */
getWidgetData(code: string, params: string) {
const url = path + "/getWidgetData";
return this.getRequest<any>({ url, params: { code: code, params: params } });
}
/** 仅 comp_type=2:按需取自定义片段内容 */
getWidgetContent(code: string) {
const url = path + "/getWidgetContent";
return this.getRequest<any>({ url, params: { code: code } });
}
getWidgetCatalogList() {
const url = path + "/GetWidgetCatalogList";
return this.getRequest<WidgetCatalog[]>({ url });
}
/** 新增/编辑全局目录(仅超管):scopeType/roleTypes/roleAlias/scopeSchIds 一并提交 */
saveWidgetCatalog(catalog: Partial<WidgetCatalog> & Record<string, any>) {
const url = path + "/SaveWidgetCatalog";
return this.postRequest<any>({ url, data: catalog });
}
/** 删除全局目录(仅超管),后端连带删点名表与校级覆盖行 */
deleteWidgetCatalog(id: number) {
const url = path + "/DeleteWidgetCatalog";
return this.postRequest<any>({ url, params: { id } });
}
/** 读本校覆盖行(编辑回显):schId 由后端取当前登录校 */
getWidgetSchoolSetting(widgetId: number) {
const url = path + "/GetWidgetSchoolSetting";
return this.getRequest<any>({ url, params: { widgetId } });
}
/** 保存本校覆盖(校管/超管):schId 由后端强制取当前登录校,前端不传 */
saveWidgetSchoolSetting(setting: Record<string, any>) {
const url = path + "/SaveWidgetSchoolSetting";
return this.postRequest<any>({ url, data: setting });
}
/** 机构树(仅超管):递归裁剪到学校层,children 即学校列表,用于可见范围多选 */
getSchTree() {
const url = path + "/getSchTree";
return this.getRequest<any>({ url });
}
}
export default new UserSettings();
+2 -2
View File
@@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0303 5L12.012 19" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 12H19" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.0303 5L12.012 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 329 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="14" y="14" width="92" height="92" rx="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="6 7" stroke-linecap="round"/>
<path d="M60 44v32M44 60h32" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" fill-rule="evenodd" d="M55.25 5.19L48.75 6.11L44.75 7.12L39.25 9.04L33.75 11.62L29.75 14.06L24.84 17.75L21.25 20.99L17.75 24.84L14.06 29.75L11.62 33.75L9.04 39.25L7.57 43.25L6.58 46.75L5.58 51.75L5.04 57.25L5.1 63.75L5.58 68.25L7.12 75.25L9.04 80.75L11.62 86.25L14.06 90.25L16.6 93.75L20.99 98.75L23.75 101.32L28.25 104.9L33.75 108.38L41.75 111.93L46.75 113.42L51.75 114.42L57.25 114.96L63.75 114.9L68.25 114.42L75.25 112.88L79.75 111.37L86.25 108.38L90.25 105.94L93.75 103.4L98.75 99.01L101.32 96.25L104.9 91.75L108.38 86.25L111.93 78.25L113.42 73.25L114.42 68.25L114.96 62.75L114.9 56.25L114.42 51.75L112.88 44.75L110.96 39.25L108.38 33.75L105.94 29.75L102.25 24.84L99.01 21.25L95.16 17.75L90.25 14.06L86.25 11.62L80.75 9.04L76.75 7.57L73.25 6.58L68.25 5.58L62.75 5.04ZM55.18 13.25L59.75 13L64.25 13.17L70.25 14.11L73.75 15.03L78.25 16.64L82.25 18.56L85.75 20.67L89.75 23.58L93.18 26.75L96.42 30.25L99.33 34.25L101.44 37.75L103.36 41.75L104.97 46.25L105.99 50.25L106.92 56.75L106.88 63.75L105.9 70.25L105.25 71.65L84.25 71.65L82.25 72.05L81.15 72.75L80.04 74.25L78.82 78.75L77.41 81.75L75.25 84.72L72.64 87.25L70.25 88.97L67.25 90.44L63.75 91.44L60.25 91.76L56.25 91.44L52.75 90.44L49.75 88.97L47.36 87.25L44.75 84.72L42.59 81.75L41.18 78.75L39.97 74.25L38.9 72.75L37.75 72.02L35.75 71.65L14.75 71.65L14.1 70.25L13.08 63.25L13.17 55.75L14.11 49.75L15.18 45.75L16.64 41.75L18.56 37.75L20.67 34.25L23.58 30.25L26.75 26.82L30.25 23.58L34.25 20.67L37.75 18.56L41.75 16.64L46.25 15.03L49.75 14.11ZM59.25 18.07L58.06 18.75L57.07 20.25L57.01 27.25L57.59 28.75L58.25 29.41L59.25 29.93L60.25 29.99L61.75 29.41L62.41 28.75L62.93 27.75L62.99 20.75L62.41 19.25L61.75 18.59L60.75 18.07ZM31.25 29.66L30.43 30.25L29.66 31.25L29.55 33.25L30.07 34.25L35.25 39.26L36.75 39.59L37.75 39.46L38.75 38.79L39.46 37.75L39.59 36.75L39.49 35.75L38.95 34.75L34.25 30.07L33.25 29.55ZM86.25 29.53L84.75 30.72L81.23 34.25L80.53 35.25L80.32 36.25L80.54 37.75L81.37 38.75L82.25 39.31L83.75 39.43L85.12 38.75L89.25 34.7L90.31 33.25L90.43 31.75L89.64 30.25L88.75 29.54L87.25 29.32ZM84.75 48.23L62.25 64.76L61.75 65.05L58.75 65.09L56.75 65.55L54.75 66.51L52.62 68.25L51.51 69.75L50.55 71.75L50.14 73.25L50.01 75.25L50.14 76.75L50.74 78.75L51.75 80.6L53.25 82.37L56.25 84.26L58.25 84.86L60.25 84.99L61.75 84.86L63.75 84.26L65.6 83.25L67.37 81.75L68.49 80.25L69.45 78.25L69.91 76.25L69.95 73.25L70.24 72.75L86.78 50.25L86.96 49.25L86.72 48.75L85.75 48.04ZM20.25 57.07L19.25 57.59L18.59 58.25L18.01 59.75L18.07 60.75L18.59 61.75L19.25 62.41L20.25 62.93L27.75 62.93L29.25 61.94L29.93 60.75L29.93 59.25L29.41 58.25L28.75 57.59L27.75 57.07ZM92.25 57.07L91.25 57.59L90.59 58.25L90.01 59.75L90.07 60.75L90.59 61.75L91.25 62.41L92.25 62.93L99.75 62.93L100.94 62.25L101.93 60.75L101.93 59.25L101.41 58.25L100.75 57.59L99.75 57.07ZM58.51 70.25L60.75 70.04L62.25 70.53L63.79 71.75L64.87 73.75L64.96 75.75L64.47 77.25L63.25 78.79L61.25 79.87L59.25 79.96L57.75 79.47L56.21 78.25L55.13 76.25L55.04 74.25L55.53 72.75L56.75 71.21Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 210 B

+79
View File
@@ -0,0 +1,79 @@
<svg width="210" height="242" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="url(#paint0_linear_25188_157943)"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="url(#paint1_linear_25188_157943)"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="url(#paint2_linear_25188_157943)"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="url(#paint3_linear_25188_157943)"/>
<rect y="182.438" width="210" height="27.5625" fill="url(#paint4_radial_25188_157943)"/>
<path opacity="0.6" d="M182.123 151.822C182.393 150.842 183.783 150.842 184.052 151.822L189.953 173.298C190.128 173.934 189.649 174.562 188.988 174.562H177.187C176.527 174.562 176.048 173.934 176.223 173.298L182.123 151.822Z" fill="url(#paint5_linear_25188_157943)"/>
<rect opacity="0.6" x="181.121" y="174.562" width="3.93417" height="13.125" fill="url(#paint6_linear_25188_157943)"/>
<path opacity="0.6" d="M25.9359 151.822C26.2052 150.842 27.5951 150.842 27.8644 151.822L33.7652 173.298C33.9401 173.934 33.4611 174.562 32.801 174.562H20.9993C20.3392 174.562 19.8602 173.934 20.0351 173.298L25.9359 151.822Z" fill="url(#paint7_linear_25188_157943)"/>
<rect opacity="0.6" x="24.9331" y="174.562" width="3.93417" height="13.125" fill="url(#paint8_linear_25188_157943)"/>
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC"/>
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="url(#paint9_linear_25188_157943)"/>
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="white"/>
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="url(#paint10_linear_25188_157943)"/>
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="url(#paint11_linear_25188_157943)"/>
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="url(#paint12_linear_25188_157943)"/>
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="url(#paint13_linear_25188_157943)"/>
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA"/>
<path d="M74.7124 230.804V231.984L71.7724 232.264V233.804H70.4524V232.364L66.2924 232.624L66.1124 231.344L70.4524 231.124V229.464H66.7324L66.4124 228.324C67.0124 227.63 67.539 226.864 67.9924 226.024H65.8524V224.744H68.6324C68.8724 224.157 69.0457 223.717 69.1524 223.424L70.5924 223.664C70.4057 224.144 70.259 224.504 70.1524 224.744H75.0324V226.024H69.5524C69.1124 226.85 68.639 227.604 68.1324 228.284H70.4524V226.964H71.7724V228.284H74.8924V229.464H71.7724V231.044L74.7124 230.804ZM77.5124 229.484C77.299 231.164 76.759 232.624 75.8924 233.864L74.8924 232.824C75.7857 231.504 76.2524 229.957 76.2924 228.184V225.044C78.7857 224.804 80.7324 224.364 82.1324 223.724L83.0524 224.804C81.5324 225.457 79.719 225.904 77.6124 226.144V228.224H83.6124V229.484H81.4924V233.824H80.1124V229.484H77.5124ZM81.3724 234.404V241.924H79.8924V241.144H69.7324V241.924H68.2524V234.404H81.3724ZM69.7324 239.904H79.8924V238.344H69.7324V239.904ZM69.7324 237.184H79.8924V235.624H69.7324V237.184ZM87.3524 224.664H101.712V226.064H94.5524V227.404C94.5124 228.724 94.4257 229.89 94.2924 230.904H103.332V232.284H97.1324V239.264C97.1324 239.917 97.4524 240.244 98.0924 240.244H100.632C101.112 240.244 101.466 240.11 101.692 239.844C101.972 239.537 102.159 238.604 102.252 237.044L103.612 237.484C103.452 239.43 103.152 240.624 102.712 241.064C102.339 241.424 101.732 241.604 100.892 241.604H97.6524C96.3457 241.604 95.6924 240.924 95.6924 239.564V232.284H94.0724C93.7524 234.017 93.2524 235.524 92.5724 236.804C91.319 238.99 89.3124 240.677 86.5524 241.864L85.7524 240.584C88.499 239.344 90.4124 237.71 91.4924 235.684C91.959 234.71 92.3257 233.577 92.5924 232.284H86.0124V230.904H92.8324C92.979 229.877 93.0657 228.71 93.0924 227.404V226.064H87.3524V224.664ZM117.852 228.024C117.719 228.504 117.626 228.857 117.572 229.084C118.066 231.35 118.719 233.344 119.532 235.064C120.306 233.037 120.732 230.69 120.812 228.024H117.852ZM118.872 236.544C118.086 234.97 117.426 233.204 116.892 231.244C116.546 232.124 116.152 232.944 115.712 233.704L114.812 232.604C115.999 230.35 116.792 227.33 117.192 223.544L118.592 223.784C118.499 224.61 118.352 225.564 118.152 226.644H123.432V228.024H122.132C122.052 231.357 121.459 234.21 120.352 236.584C121.406 238.344 122.639 239.697 124.052 240.644L123.232 241.864C121.859 240.904 120.666 239.59 119.652 237.924C118.679 239.524 117.439 240.864 115.932 241.944L115.172 240.724C116.732 239.644 117.966 238.25 118.872 236.544ZM110.772 233.024L110.372 234.124H114.532V235.344C114.092 236.65 113.366 237.784 112.352 238.744C113.299 239.144 114.199 239.584 115.052 240.064L114.272 241.224C113.352 240.677 112.339 240.157 111.232 239.664C109.886 240.584 108.219 241.324 106.232 241.884L105.532 240.644C107.199 240.204 108.599 239.657 109.732 239.004L106.952 237.884C107.512 236.964 107.966 236.144 108.312 235.424H105.972V234.124H108.912C109.246 233.19 109.399 232.77 109.372 232.864L110.772 233.024ZM110.972 238.164C111.972 237.35 112.706 236.437 113.172 235.424H109.792C109.459 236.104 109.099 236.737 108.712 237.324C109.512 237.604 110.266 237.884 110.972 238.164ZM107.552 224.024C108.072 224.837 108.499 225.657 108.832 226.484L107.712 227.004C107.299 226.057 106.846 225.197 106.352 224.424L107.552 224.024ZM114.732 224.444C114.319 225.35 113.806 226.217 113.192 227.044L112.112 226.464C112.752 225.557 113.246 224.697 113.592 223.884L114.732 224.444ZM106.032 227.424H109.932V223.604H111.272V227.424H114.912V228.684H111.272V228.904C112.366 229.45 113.472 230.064 114.592 230.744L113.852 231.864C112.852 231.064 111.992 230.424 111.272 229.944V232.544H109.932V228.744C109.279 230.304 108.086 231.67 106.352 232.844L105.652 231.684C107.199 230.777 108.272 229.777 108.872 228.684H106.032V227.424ZM126.272 241.744L125.972 240.384C126.546 240.45 127.052 240.484 127.492 240.484C127.999 240.484 128.252 240.19 128.252 239.604V234.724C127.319 235.07 126.532 235.344 125.892 235.544L125.532 234.144C126.439 233.93 127.346 233.657 128.252 233.324V228.804H125.852V227.424H128.252V223.644H129.652V227.424H131.572V228.804H129.652V232.744C130.292 232.464 130.912 232.164 131.512 231.844V233.264C131.099 233.477 130.479 233.77 129.652 234.144V239.964C129.652 241.15 129.092 241.744 127.972 241.744H126.272ZM133.952 232.904C133.712 236.317 132.932 239.324 131.612 241.924L130.492 240.924C131.906 238.284 132.626 234.857 132.652 230.644V224.384H143.192V229.244H139.432V231.604H143.712V232.904H139.432V235.364H142.792V241.924H141.452V240.944H136.032V241.924H134.692V235.364H138.032V232.904H133.952ZM138.032 229.244H134.052L134.032 231.604H138.032V229.244ZM136.032 239.664H141.452V236.644H136.032V239.664ZM141.792 225.724H134.052V227.944H141.792V225.724Z" fill="#969799"/>
<defs>
<linearGradient id="paint0_linear_25188_157943" x1="-3.63248" y1="171.938" x2="-3.63248" y2="69.5625" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.5"/>
<stop offset="1" stop-color="#F2F3F5"/>
</linearGradient>
<linearGradient id="paint1_linear_25188_157943" x1="140.274" y1="35.8711" x2="140.274" y2="7.875" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2F3F5" stop-opacity="0.3"/>
<stop offset="1" stop-color="#F2F3F5"/>
</linearGradient>
<linearGradient id="paint2_linear_25188_157943" x1="37.8985" y1="43.5495" x2="37.8985" y2="30.1875" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2F3F5" stop-opacity="0.3"/>
<stop offset="1" stop-color="#F2F3F5"/>
</linearGradient>
<linearGradient id="paint3_linear_25188_157943" x1="207.83" y1="139.609" x2="207.83" y2="19.6875" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.5"/>
<stop offset="1" stop-color="#F2F3F5"/>
</linearGradient>
<radialGradient id="paint4_radial_25188_157943" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(105 182.438) rotate(90) scale(27.5625 877.353)">
<stop stop-color="#EBEDF0"/>
<stop offset="1" stop-color="white" stop-opacity="0.01"/>
</radialGradient>
<linearGradient id="paint5_linear_25188_157943" x1="175.875" y1="148.312" x2="175.875" y2="174.562" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2F3F5"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint6_linear_25188_157943" x1="185.055" y1="187.688" x2="185.055" y2="174.562" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint7_linear_25188_157943" x1="19.6875" y1="148.312" x2="19.6875" y2="174.562" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2F3F5"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint8_linear_25188_157943" x1="28.8673" y1="187.688" x2="28.8673" y2="174.562" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint9_linear_25188_157943" x1="57.75" y1="65.625" x2="57.75" y2="152.25" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2F3F5"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint10_linear_25188_157943" x1="97.3111" y1="84" x2="95.1448" y2="97.782" gradientUnits="userSpaceOnUse">
<stop stop-color="#EAEDF1"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint11_linear_25188_157943" x1="97.3111" y1="102.375" x2="95.1448" y2="116.157" gradientUnits="userSpaceOnUse">
<stop stop-color="#EAEDF1"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint12_linear_25188_157943" x1="97.3111" y1="120.75" x2="95.1448" y2="134.532" gradientUnits="userSpaceOnUse">
<stop stop-color="#EAEDF1"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
<linearGradient id="paint13_linear_25188_157943" x1="-4.18762" y1="164.063" x2="19.2534" y2="229.682" gradientUnits="userSpaceOnUse">
<stop stop-color="#EAEDF1"/>
<stop offset="1" stop-color="#DCDEE0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+71 -5
View File
@@ -1,12 +1,26 @@
.widget-wrap {
// 玻璃层令牌(方案 §6.2):深底 + 模糊 + 描边 + 投影,前景走 token,不再随壁纸漂移
--wg-glass-bg: rgba(15, 23, 42, 0.42);
--wg-glass-border: rgba(255, 255, 255, 0.18);
--wg-glass-shadow: 0 10px 36px rgba(0, 0, 0, 0.3);
--wg-fg: rgba(255, 255, 255, 0.94);
--wg-fg-dim: rgba(255, 255, 255, 0.66);
--wg-fg-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
width: 380px;
min-height: 200px;
z-index: 100;
border-radius: 5px;
background: rgba(0, 0, 0, 0.35);
background: var(--wg-glass-bg);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border: 1px solid var(--wg-glass-border);
box-shadow: var(--wg-glass-shadow);
.widget-header {
display: flex;
height: 48px;
color: var(--wg-fg-dim);
text-shadow: var(--wg-fg-shadow);
.widget-header-drag {
flex: 2 auto;
cursor: move;
@@ -18,6 +32,10 @@
cursor: pointer;
width: 24px;
flex-shrink: 1;
color: var(--wg-fg-dim);
}
&:hover svg {
color: var(--wg-fg);
}
}
.operate-btn:nth-child(2) {
@@ -30,12 +48,24 @@
height: calc(100% - 48px);
.widget-item {
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
overflow: hidden;
// 内容卡不透明白底。旧版内容跑在 <iframe> 里、由 iframe 自带白底提供,
// 内置组件化后丢失 ⇒ 只剩 95% 白叠玻璃层 ≈ rgb(248,249,251),与卡头 #f7f8f9 只差 1~2 级,整卡糊成一色。
background: #fff;
// 内置组件的浮层(如课表班级切换下拉)需溢出卡片,故不裁切;
// 卡片圆角由自身背景维持,疑似溢出子元素(iframe)自带底部圆角。
overflow: visible;
margin: 18px 14px 0 14px;
&:first-child {
margin-top: 0;
}
.no-data {
min-height: 90px;
display: flex;
align-items: center;
justify-content: center;
color: #b9c2cc;
font-size: 13px;
}
}
.widget-app-header {
@@ -45,25 +75,56 @@
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e9e9e9;
// 卡头浅灰底(原版观感;合成色曾随壁纸漂移,这里显式钉死)
background: #f7f8f9;
// .widget-item 已改 overflow:visible,卡头底色的顶部圆角需自补
border-radius: 6px 6px 0 0;
color: #344449;
font-weight: 700;
font-size: 16px;
svg {
cursor: pointer;
}
// 右区:内置组件 header 控件挂载点 + 关闭按钮(.widget-app-header 是 space-between,右区整体靠右)
.widget-header-right {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.widget-header-extra {
display: flex;
align-items: center;
}
}
iframe {
width: 100%;
display: block;
min-height: 150px;
// .widget-item 已改为 overflow:visible,页面片段的底部圆角自行补齐
border-radius: 0 0 6px 6px;
}
}
// 拖拽态翻白(沿用旧版白色语义:面板与 header 一并提亮,模糊略加强)。
// ⚠️ 该元素同时挂着 .widget-wrap,--wg-fg* 是给深色玻璃配的白字,
// 翻白后必须同步反转为深色,否则重演「白底压白字 / 浅灰图标不可见」。
&__drag {
--wg-glass-bg: rgba(255, 255, 255, 0.6);
--wg-glass-border: rgba(15, 23, 42, 0.08);
--wg-glass-shadow: 0 14px 44px rgba(15, 23, 42, 0.2);
--wg-fg: #1f2d3d;
--wg-fg-dim: rgba(31, 45, 61, 0.62);
--wg-fg-shadow: none;
background: var(--wg-glass-bg);
backdrop-filter: blur(22px) saturate(150%);
-webkit-backdrop-filter: blur(22px) saturate(150%);
box-shadow: var(--wg-glass-shadow);
.widget-header {
background: rgba(255, 255, 255, 0.5);
}
background: rgba(255, 255, 255, 0.6);
}
}
@@ -73,7 +134,12 @@
align-items: center;
justify-content: center;
flex-direction: column;
color: #828c96;
color: var(--wg-fg);
text-shadow: var(--wg-fg-shadow);
svg {
color: var(--wg-fg-dim);
}
h4 {
font-weight: 500;
+152 -76
View File
@@ -24,10 +24,25 @@
box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
}
& .logo-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
}
& .logo-box {
height: 36px;
}
& .logo-text {
margin-left: 10px;
font-size: 22px;
font-weight: 700;
color: #252b35;
white-space: nowrap;
letter-spacing: 0.5px;
}
& .operate-container {
flex-shrink: 1;
& > li {
@@ -58,96 +73,157 @@
height: 20px;
}
.search-box {
height: 36px;
margin-right: 2px;
}
}
/* ===== Header 搜索命令面板(2026-10-08 定稿 B 方案)=====
⚠️ 必须放在顶层(不可嵌进 .default-header 内):面板由 <Teleport to="body"> 渲染到 body 下,
若嵌在 .default-header 内会生成 `.default-header .app-cmdk-layer`,匹配不到 body 下的元素。
类名统一 app-cmdk- 前缀(本文件是全局样式,非 scoped)。 */
.app-cmdk-layer {
position: fixed;
inset: 0;
z-index: 2000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s linear;
&.is-open {
opacity: 1;
pointer-events: auto;
}
}
.app-cmdk-mask {
position: absolute;
inset: 0;
background: rgba(18, 26, 40, 0.34);
backdrop-filter: blur(10px) saturate(125%);
-webkit-backdrop-filter: blur(10px) saturate(125%);
}
.app-cmdk-panel {
position: absolute;
left: 50%;
top: 78px;
width: min(620px, calc(100% - 48px));
background: #fff;
border-radius: 34px;
display: flex;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 26px 68px rgba(18, 30, 54, 0.3), 0 2px 8px rgba(18, 30, 54, 0.1);
opacity: 0;
transform: translate(-50%, -14px) scale(0.965);
transition: opacity 0.26s linear, transform 0.48s cubic-bezier(0.2, 0.9, 0.24, 1);
.app-cmdk-layer.is-open & {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
.app-cmdk-hd {
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
gap: 12px;
padding: 0 18px;
height: 56px;
border-bottom: 1px solid #f0f3f8;
color: #98a2b0;
input {
flex: 1;
min-width: 0;
outline: none;
border: none;
margin-left: 15px;
width: 210px;
}
.icon {
margin: 0 8px;
// animation: icon-turn 0.3s linear;
border: 0;
color: #252b35;
font-size: 15px;
background: none;
&::placeholder {
color: #aab3c0;
}
}
@keyframes fade-animation {
0% {
display: none;
}
75% {
display: none;
}
100% {
}
.app-cmdk-kbd {
font-size: 11px;
color: #8b95a3;
background: #f2f5fa;
border: 1px solid #e4e9f2;
border-radius: 5px;
padding: 2px 6px;
font-family: ui-monospace, Menlo, monospace;
}
.app-cmdk-list {
padding: 8px;
max-height: min(58vh, 440px);
overflow: auto;
}
.app-cmdk-item {
display: flex;
}
}
.fade1-enter-active,
.fade1-enter-to {
animation: fade-animation 0.5s;
}
align-items: center;
gap: 12px;
padding: 9px 12px;
border-radius: 10px;
cursor: pointer;
opacity: 0;
transform: translateY(9px);
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.42s cubic-bezier(0.4, 0, 0.2, 1), background 0.16s;
.fade1-leave-from,
.fade1-leave-active {
display: none !important;
.app-cmdk-layer.is-open & {
opacity: 1;
transform: none;
/* --i 封顶 20:应用多时(100+)末尾条目延迟不再线性膨胀到数秒,滚动空白窗上限 ≈0.84s */
transition-delay: calc(0.14s + min(var(--i), 20) * 0.035s),
calc(0.14s + min(var(--i), 20) * 0.035s), 0s;
}
@keyframes search-animation {
0% {
&.is-hl,
&:hover {
background: #f3f6fc;
}
}
.app-cmdk-list.is-instant .app-cmdk-item {
transition-duration: 0.15s;
transition-delay: 0s;
}
.app-cmdk-img {
width: 34px;
height: 34px;
border-radius: 9px;
flex-shrink: 0;
}
.app-cmdk-name {
flex: 1;
min-width: 0;
font-size: 13.5px;
color: #252b35;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 应用类型:右侧灰字(来源 BoaAppType.name,与 /apps 分组标题同源) */
.app-cmdk-type {
flex-shrink: 0;
font-size: 11.5px;
color: #98a2b0;
font-weight: 400;
white-space: nowrap;
}
.app-cmdk-empty {
display: none;
width: 0;
}
20% {
display: none;
width: 0;
}
21% {
width: 0;
padding: 28px 18px 32px;
text-align: center;
color: #a8b1be;
font-size: 13px;
&.is-show {
display: block;
}
100% {
width: 210px;
}
}
@keyframes icon-animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.search-enter-active {
& input {
animation: search-animation 0.3s ease-in-out;
}
& .icon {
animation: icon-animation 0.4s;
}
}
.search-leave-active {
& input {
animation: search-animation 0.35s ease-out reverse;
}
& .icon {
animation: icon-animation 0.4s reverse;
}
}
}
}
.app-cmdk-ft {
display: flex;
gap: 16px;
padding: 10px 18px;
border-top: 1px solid #f0f3f8;
font-size: 11.5px;
color: #98a2b0;
background: #fbfcfe;
}
.role-info {
+27
View File
@@ -152,6 +152,33 @@ div.dib {
}
}
// 分段 Tab(设置中心「桌面应用」角色分段 / 系统设置「应用管理」角色分段共用,替代 iView Tabs 观感)
.seg-tabs {
display: inline-flex;
background: #f0f2f6;
border-radius: 9px;
padding: 3px;
gap: 2px;
.sg {
padding: 6px 16px;
font-size: 13px;
border-radius: 7px;
color: #7a8088;
cursor: pointer;
transition: all 0.15s;
user-select: none;
&:hover {
color: #5979ea;
}
&.active {
background: #fff;
color: #5979ea;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
}
}
// .chart-box {
// height: 50px;
// width: 100%;
+190 -54
View File
@@ -1,6 +1,9 @@
<template>
<header :class="headerClass">
<div class="logo-wrap">
<img v-if="!!logoUrl" :src="baseUrl + '/image/default-logo.png'" v-real-img="logoUrl" class="logo-box" alt="" />
<span v-if="logoText" class="logo-text">{{ logoText }}</span>
</div>
<ul class="operate-container fix">
<li v-if="isWidgetIconShow" :class="{ active: isWidgetShow }">
<div class="icon-box" v-wave @click="toggleWidgetShow">
@@ -8,17 +11,9 @@
</div>
</li>
<li v-if="isSearchIconShow">
<Transition name="fade1" :duration="{ enter: 1000, leave: 0 }">
<div v-show="!isSearching" @click="isSearching = true" class="icon-box" v-wave>
<div class="icon-box" v-wave @click="openSearch">
<SvgIcon name="header-1" color="#333333" />
</div>
</Transition>
<Transition name="search" :duration="{ enter: 300, leave: 300 }">
<div v-show="isSearching" class="search-box">
<input ref="refSearchInput" v-model="searchAppText" type="text" placeholder="请输入应用名称搜索" />
<SvgIcon @click="isSearching = false" class="icon" name="header-1" size="20" color="#c4c4c4" />
</div>
</Transition>
</li>
<!-- 人像图标 =唯一入口(2026-10-08整合):铃铛与齿轮两枚图标下线,
消息中心仍走左侧菜单 /msg(LeftMenu 保留未读角标);
@@ -84,6 +79,9 @@
</ul>
</header>
<!-- WS 实时推送卡片(design/03 .ws-toast),Teleport 已落到 body -->
<WsToastLayer ref="wsToastRef" />
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
:transfer="false">
<vuescroll>
@@ -178,33 +176,83 @@
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
<MsgApprovalModal ref="refMsgApproval" />
<StatsBarModal ref="refStatsBarModal" />
<TeacherCopilotModal ref="refTeacherCopilotModal" />
<!-- 搜索命令面板(2026-10-08 定稿 B 方案):必须 Teleport 到 body ——
.default-header 有 position:relative + z-index:10,会创建层叠上下文,
面板写在 Header 内部时无论 z-index 多高,对外只表现为 10,会被左侧菜单与内容区盖住 -->
<Teleport to="body">
<div class="app-cmdk-layer" :class="{ 'is-open': isSearching }">
<div class="app-cmdk-mask" @click="closeSearch"></div>
<div class="app-cmdk-panel">
<div class="app-cmdk-hd">
<SvgIcon name="header-1" size="19" color="#98a2b0" />
<input
ref="refSearchInput"
v-model="searchAppText"
type="text"
placeholder="输入应用名以搜索"
autocomplete="off"
/>
<span class="app-cmdk-kbd">Esc</span>
</div>
<div class="app-cmdk-list" :class="{ 'is-instant': !cmdkStagger }" ref="refCmdkList">
<div
v-for="(item, i) in cmdkResult"
:key="item.id ?? i"
class="app-cmdk-item"
:class="{ 'is-hl': i === cmdkIndex }"
:style="{ '--i': i }"
@click="pickApp(item)"
>
<AppIcon
v-if="vectorIconOf(item)"
:name="vectorIconOf(item)"
scene="tile"
:tint="item.iconColor"
/>
<img v-else :src="$utils.appIconPath(item.icon)" class="app-cmdk-img" alt="" />
<span class="app-cmdk-name">{{ item.name }}</span>
<span class="app-cmdk-type" v-if="item.appTypeName">{{ item.appTypeName }}</span>
</div>
</div>
<div class="app-cmdk-empty" :class="{ 'is-show': !cmdkResult.length }">
{{ store.appListLoading ? "正在加载应用…" : "未找到匹配的应用" }}
</div>
<div class="app-cmdk-ft">
<span>↑↓ 选择</span><span>↵ 打开</span><span>Esc 关闭</span>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
import vuescroll from "vuescroll";
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch } from "vue";
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch, nextTick } from "vue";
import { useDefaultStore } from "@/store";
import { useRoute, useRouter } from "vue-router";
import { requireImg } from "@/utils/hook/useImg";
import ContextMenu from "@/components/ContextMenu.vue";
import StatsBarModal from "@/components/StatsBarModal.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi";
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
import { Notice } from "view-ui-plus";
import WsToastLayer from "@/components/WsToastLayer.vue";
import { IframePageParmas, RoleInfo } from "@/types";
import { isEqual, orderBy } from "lodash";
import UserSettingApi from "@/api/UserSettingApi";
import mittBus from "@/utils/mitt";
import MenuBarApi from "@/api/MenuBarApi";
import Utils from "@/utils";
import AppIcon from "@/components/AppIcon.vue";
import { appiconKeyOf } from "@/constants/appIconRules";
import { openAppPage } from "@/utils/bschLinkPage";
const route = useRoute();
const router = useRouter();
const wsToastRef = ref<InstanceType<typeof WsToastLayer>>();
const store = useDefaultStore();
const refMsgApproval = ref<any>();
const { $conf } = useCurrentInstance();
@@ -214,7 +262,6 @@ const baseUrl = computed(() => $conf.fileHost);
* 但摘要拉取与 WS 推送仍需保留 —— /msg 页左栏与 LeftMenu 角标与本store 同源 */
const refSearchInput = ref<any>();
const refStatsBarModal = ref<any>();
const refTeacherCopilotModal = ref<any>();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
@@ -225,6 +272,8 @@ const logoUrl = computed(() => {
const sysLogo = logoList.value.find(x => x.type == 0);
return sysLogo ? sysLogo.url : "";
});
/** LOGO 文字(平台/学校名称,2026-10-09):存 sch_settings.logo_text(级联读),与 LOGO 图独立 */
const logoText = ref("");
const isUserCardShow = ref(false),
isWallpaperShow = ref(false),
isLogoShow = ref(false),
@@ -293,25 +342,33 @@ async function connectMsgWs() {
};
}
/** WS 帧按 appKey 匹配应用名(store 应用列表);查无回退类型标签(缺失降级,不报错) */
function frameAppName(frame: any) {
const appKey = frame && frame.appKey;
if (appKey) {
const hit = store.getFlattenAppList.find((x: any) => x.appKey == appKey || x.appCode == appKey);
if (hit && hit.name) return hit.name;
}
return msgTypeLabel(frame ? frame.msgType : null);
}
function onNewMessage(frame: any) {
// 他校新消息对当前学校口径无信息量(不计未读、不打扰、也不触发 /msg 页重拉)
if (!bellVisibleForCurrentSchool(frame)) {
return;
}
store.incUnread();
// /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
mittBus.emit("msgCenter:newMessage", frame);
Notice.info({
title: `【${frameAppName(frame)}】新消息`,
desc: frame.content || "",
duration: 5,
});
// design/03 .ws-toast 实时卡片(2026-10-09):类型色条+图标章+进度条自动消退,点击进 /msg
wsToastRef.value?.push(frame);
}
/**
* 铃铛按校过滤(消息按学校隔离 批次4):口径必须与批次3 服务端列表一致——
* 平台级消息(帧无 schId)任何身份都提醒;平台/超管身份(getCurSchId 非正数,含 schId=0
* 被 store 归一成 -1 的形态)看全部,一律提醒;其余只认当前激活身份学校。
*/
function bellVisibleForCurrentSchool(frame: any): boolean {
const frameSchId = frame ? frame.schId : null;
if (frameSchId === null || frameSchId === undefined) {
return true;
}
const curSchId = store.getCurSchId;
if (!curSchId || curSchId <= 0) {
return true;
}
return Number(frameSchId) === Number(curSchId);
}
function closeMsgWs() {
@@ -360,22 +417,109 @@ onMounted(async () => {
isSearching.value = false;
searchAppText.value = null;
});
window.addEventListener("keydown", onCmdkKeydown);
});
onUnmounted(() => {
mittBus.off("searchReset");
mittBus.off("switchRole", onSwitchRoleRefresh);
window.removeEventListener("keydown", onCmdkKeydown);
closeMsgWs();
});
watch(isSearching, val => {
if (val) {
setTimeout(() => refSearchInput.value?.focus(), 300);
} else {
// searchAppText.value = null;
// 切换身份后角标随新身份学校口径刷新(emit 在 savUserRole 落库之后,服务端 identity/current 已是新校)
function onSwitchRoleRefresh() {
loadSummary();
}
mittBus.on("switchRole", onSwitchRoleRefresh);
// 面板已是模态框,输入只驱动面板自身过滤;
// 不再 emit("searchChange") —— 背景应用网格保持原样(2026-10-08 用户修订 1)
watch(searchAppText, () => {
cmdkStagger.value = false; // 输入过滤时不重播逐条入场
cmdkIndex.value = 0; // 过滤使结果集变化,高亮回归首项(否则旧索引落空/错位,Enter 哑炮)
});
// ===== 搜索命令面板(2026-10-08 定稿 B 方案)=====
const refCmdkList = ref<any>();
const cmdkIndex = ref(0);
const cmdkStagger = ref(true); // 仅首次打开做逐条入场,输入过滤时不重播
/** 与 AppList.vue / index.vue / SearchPanel.vue 同口径的矢量渲染回退链 */
function vectorIconOf(item: any): string {
if (item?.iconKey) return item.iconKey;
if (item?.icon) return "";
return appiconKeyOf(item?.name || "");
}
/** 结果取自 store.curUserAppList(写入方 = AppList.reloadAppData)。
* 它是 BoaAppType 分组结构 [{ id, name, apps }],name 就是应用类型名;
* 展开时把类型名挂到每条上供右侧灰字展示。
* 注意别用 store.getFlattenAppList —— 它是纯 flatten,会把类型名丢掉 */
const cmdkResult = computed(() => {
const q = (searchAppText.value || "").trim();
const out: any[] = [];
(store.curUserAppList || []).forEach((group: any) => {
(group.apps || []).forEach((app: any) => {
if (!q || (app.name || "").indexOf(q) !== -1) {
out.push({ ...app, appTypeName: group.name });
}
});
});
return out;
});
watch(searchAppText, val => {
mittBus.emit("searchChange", val);
});
function openSearch() {
cmdkStagger.value = true;
cmdkIndex.value = 0;
searchAppText.value = null;
isSearching.value = true;
nextTick(() => refSearchInput.value?.focus());
}
function closeSearch() {
isSearching.value = false;
searchAppText.value = null;
}
function moveHighlight(step: number) {
const len = cmdkResult.value.length;
if (!len) return;
cmdkIndex.value = (cmdkIndex.value + step + len) % len;
nextTick(() => {
const el = refCmdkList.value?.children?.[cmdkIndex.value];
el?.scrollIntoView({ block: "nearest" });
});
}
function pickApp(item: any) {
closeSearch();
openAppPage(item, $conf.ifameAppIdArr); // 与点击应用图标同一条路径
}
function onCmdkKeydown(ev: KeyboardEvent) {
if (!isSearching.value) return; // 面板未开:绝不拦截全局按键
// IME 组合态(拼音候选未上屏)不响应导航/确认:keydown 的 Enter/↑↓ 属输入法内部按键,
// 不拦截防误开应用、不 preventDefault 防干扰候选词选择(keyCode 229 为旧式组合态标记)
if (ev.isComposing || ev.keyCode === 229) return;
if (ev.key === "Escape") {
closeSearch();
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
moveHighlight(1);
return;
}
if (ev.key === "ArrowUp") {
ev.preventDefault();
moveHighlight(-1);
return;
}
if (ev.key === "Enter") {
ev.preventDefault();
const it = cmdkResult.value[cmdkIndex.value];
if (it) pickApp(it);
}
}
function getIsCheckRoles(item: RoleInfo) {
return isEqual(item, store.curRoleInfo);
}
@@ -392,11 +536,6 @@ async function changeRole(role: RoleInfo) {
await loadLogoList();
}
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办);WS 帧缺 appName 时的降级标签 */
function msgTypeLabel(msgType: number | null) {
return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知";
}
async function wallpaperSuccess(response: any, file: any, fileListt: any) {
await getUserWallpaperList();
}
@@ -423,11 +562,6 @@ function linkSidebarSetting() {
router.push("/settings?pane=sidebar");
}
async function settingsStats() {
isUserCardShow.value = false;
refStatsBarModal.value.show();
}
async function settingsTeacherCopilot() {
isUserCardShow.value = false;
refTeacherCopilotModal.value.show();
@@ -446,11 +580,20 @@ async function onClickWallpaper(url: string) {
async function onWallpaperSelect(item: any) {
await UserSettingApi.deleteWallById(item.id);
await getUserWallpaperList();
// 删除的是当前生效壁纸时重拉用户设置(后端兜底接管),桌面即时回落不白板
if (store.getCurWallPaperUrl === item.url) {
const userSetting = await UserSettingApi.getUserSettings();
if (userSetting) store.setCurUserSettingsInfo(userSetting);
}
}
async function loadLogoList() {
const list = await UserSettingApi.getLogoList();
logoList.value = list || [];
// LOGO 文字与图并行加载(失败静默——无文字仅显示图,不影响 header)
BaseApi.getSchLogoText()
.then(res => (logoText.value = res?.logoText || ""))
.catch(() => {});
}
async function logoSuccess(response: any, file: any, fileListt: any) {
@@ -470,11 +613,6 @@ async function toggleWidgetShow() {
await UserSettingApi.toggleWidgetShow(id);
}
function linkSysSetting() {
isUserCardShow.value = false;
router.push("/syssetting");
}
function linkBackendManage() {
isUserCardShow.value = false;
const url = `${$conf.baseHost}/userbase/front/index.html`;
@@ -487,8 +625,6 @@ async function linkLogoSetting() {
isLogoShow.value = true;
}
function showSeachBox() { }
/** 卡片菜单:个人信息 → 设置中心 profile pane(与通知端/用户中心同一地址)。
* 注意:mittBus 的 showUserInfoModal 事件仍由 AppHeader/BschIframe 使用,此处不再触发 */
function showUserInfo() {
-301
View File
@@ -1,301 +0,0 @@
<template>
<Modal
v-model="isTableModalShow"
class-name="menuwapper-modal"
width="1160"
:mask="true"
title="统计模块设置"
:transfer="false"
>
<div class="fix mb-10">
<Button type="primary" size="small" class="fl ml-10 width-10" ghost @click="onEditFormShow()">
新建统计模块
</Button>
</div>
<Table
:columns="menuBarColumns"
:height="360"
:loading="tableLoading"
:data="tableList"
class="link-table"
no-data-text="暂无数据"
>
<template #icon="{ row }">
<img class="icon-box" v-if="!!row.icon" :src="$conf.serviceHost + row.icon" alt="" />
</template>
<template #isShow="{ row }">
<Switch :model-value="row.isShow" true-color="#5979ea" @on-change="onMenuBarShowChange(row.id)" />
</template>
<template #roleIds="{ row }">
<span>{{ row.roleIds.map((item: number) => RoleTypeDict.find(x => x.k == item)?.v).join(",") }}</span>
</template>
<!-- <template #orderNum="{ row }">
<InputNumber :model-value="row.orderNum" :min="1" />
</template> -->
</Table>
<template #footer>
<Button type="primary" ghost @click="isTableModalShow = false">关闭</Button>
</template>
</Modal>
<Modal
v-model="isEditModalShow"
scrollable
width="550"
:title="(!formData.id ? '添加' : '编辑') + '统计模块'"
:transfer="true"
>
<Form ref="refEditForm" class="mr-30" :model="formData" :rules="ruleValidate" :label-width="110">
<Form-Item label="权限角色:" class="fix" prop="roleIds">
<Checkbox
class="fl"
:indeterminate="indeterminate"
:model-value="compCheckAll"
@click.prevent="handleCheckAll"
>
全部
</Checkbox>
<CheckboxGroup v-model="formData.roleIds">
<Checkbox v-for="item in RoleTypeDict" :label="item.k">{{ item.v }}</Checkbox>
</CheckboxGroup>
</Form-Item>
<Form-Item label="名称:" prop="name">
<Input max="4" v-model="formData.name" maxlength="30" placeholder="请输入名称" />
</Form-Item>
<Form-Item label="网址:" prop="url">
<Input v-model="formData.url" placeholder="请输入链接" />
</Form-Item>
<Form-Item label="图标:" prop="icon">
<Upload
:on-success="uploadIconSuccess"
:show-upload-list="false"
:action="$conf.serviceHost + '/api/iconUpload'"
:format="['jpg', 'jpeg', 'png', 'gif']"
accept="image/gif,image/jpeg,image/jpg,image/png"
:with-credentials="true"
:headers="uploadHeaders"
>
<div v-if="!formData.icon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon
@click.stop="formData.icon = null"
class="cloase-btn"
name="widget-close"
size="18"
color="#fff"
/>
<img :src="$conf.serviceHost + formData.icon" alt="" />
</div>
</Upload>
</Form-Item>
<Form-Item label="排序号:" prop="orderNum">
<InputNumber style="width: 120px" v-model="formData.orderNum" :min="1" placeholder="请输入排序号" />
</Form-Item>
<Form-Item label="打开方式:" prop="linkOpenType">
<RadioGroup v-model="formData.linkOpenType" type="button" button-style="solid">
<Radio :label="item.k" v-for="item in linkOpenTypeDict">{{ item.v }}</Radio>
</RadioGroup>
</Form-Item>
</Form>
<template #footer>
<Button type="primary" :loading="editLoading" @click="onEditSave">保存</Button>
<Button @click="isEditModalShow = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import StatsBarApi from "@/api/StatsBarApi";
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeEnum, RoleTypeDict } from "@/types";
import Utils from "@/utils";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { Message } from "view-ui-plus";
import { ref, reactive, computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import { BaseDict } from "@/types/Basics";
import { find } from "lodash";
interface FormData {
id: string | null;
roleIds: number[];
name: string | null;
url: string | null;
linkOpenType: number;
icon: string | null;
orderNum: number | null;
isShow: boolean;
isEdit: boolean;
}
// 初始化 formData 的默认值
const defaultFormData: FormData = {
id: null,
roleIds: [], // 添加 roleIds 属性
name: "",
url: "",
linkOpenType: 0,
icon: "",
orderNum: null,
isShow: true,
isEdit: true,
};
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const refEditForm = ref<any>(null);
const isTableModalShow = ref(false),
isEditModalShow = ref(false),
tableLoading = ref(false),
editLoading = ref(false),
tableList = ref<any[]>([]),
formData = reactive(defaultFormData);
const ruleValidate = ref({
roleIds: { required: true, type: "array", message: "请选择权限角色", trigger: "change,submit" },
name: { required: true, message: "请输入名称", trigger: "blur" },
url: { required: true, message: "请输入网址", trigger: "blur" },
icon: { required: true, message: "请上传图标", trigger: "change,submit" },
});
const linkOpenTypeDict = computed(() => LinkOpenTypeDict.filter(x => x.k != LinkOpenTypeEnum.Modal));
const compCheckAll = computed(() => formData.roleIds.length == RoleTypeDict.length && RoleTypeDict.length > 0);
const indeterminate = computed(() => formData.roleIds.length > 0 && formData.roleIds.length < RoleTypeDict.length);
const menuBarColumns = ref([
{
title: "名称",
key: "name",
},
{
title: "图标",
slot: "icon",
},
{
title: "角色",
width: 150,
slot: "roleIds",
},
{
title: "地址",
key: "url",
width: 150,
tooltip: true,
},
{
title: "打开方式",
key: "linkType",
},
{
title: "是否显示",
width: 100,
align: "center",
slot: "isShow",
},
{
title: "排序号",
key: "orderNum",
align: "center",
},
{
title: "操作",
width: 120,
align: "center",
render: (h: any, params: any) => {
const disabled = !params.row.isEdit,
id = params.row.id;
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => onEditFormShow(id), disabled),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteMenu(id), disabled),
];
},
},
]);
async function show() {
isTableModalShow.value = true;
await getMenuBarList();
console.log("show");
}
async function getMenuBarList() {
tableLoading.value = true;
const list = await StatsBarApi.getStatsBarSchList();
list.forEach(element => {
element.linkType = find(linkOpenTypeDict.value, { k: element.linkOpenType })?.v;
});
tableList.value = list;
tableLoading.value = false;
}
function handleCheckAll() {
formData.roleIds = !compCheckAll.value ? RoleTypeDict.map(item => item.k) : [];
}
function uploadIconSuccess(response: any, file: any, fileListt: any) {
if (response.code == 0) formData.icon = response.data.url;
else Message.error("上传错误!");
// console.log(response, file, fileListt);
}
async function onEditFormShow(id?: number) {
await refEditForm.value?.resetFields();
isEditModalShow.value = false;
if (!id) {
formData.id = null;
formData.linkOpenType = LinkOpenTypeEnum.Open;
} else {
let find = tableList.value.find(x => x.id == id);
Object.assign(formData, find);
}
isEditModalShow.value = true;
}
async function onDeleteMenu(id: number) {
await MyConfirm({
title: "删除",
msg: "确定删除该统计模块吗?",
info: "删除后无法恢复!",
async onOk() {
await StatsBarApi.deleteStatsBar(id);
},
});
await getMenuBarList();
}
async function onEditSave() {
let validate: boolean = await refEditForm.value?.validate();
if (!validate) return false;
editLoading.value = true;
try {
await StatsBarApi.saveStatsBar(formData);
Message.success("操作成功!");
await getMenuBarList();
isEditModalShow.value = false;
} catch (error) {
Message.success("操作失败!");
} finally {
editLoading.value = false;
}
}
function onMenuBarShowChange(id: number) {
StatsBarApi.toggleStatsBarShow(id);
}
defineExpose({
show,
});
</script>
<style scoped>
.icon-box {
width: 20px;
height: 20px;
}
</style>
+11 -2
View File
@@ -4,7 +4,7 @@
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
@@ -22,7 +22,16 @@ const props = withDefaults(
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
const style = computed(() => resolveAvatarStyle());
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
+275
View File
@@ -0,0 +1,275 @@
<template>
<Teleport to="body">
<div class="ws-toast-layer">
<div v-for="t in toasts" :key="t.id" class="ws-toast" :class="[t.cls, { out: t.leaving }]"
@click="goMsgCenter(t)">
<div class="wt">
<div class="ic">{{ t.ic }}</div>
<div class="meta">
<div class="nm">{{ t.name }}</div>
<div class="tm">{{ t.tm }}</div>
</div>
<div class="x" @click.stop="dismiss(t)">✕</div>
</div>
<div class="wc"><span class="tt">{{ t.title }}</span>{{ t.content }}</div>
<div class="wf">
<div class="progress"><i :style="{ animationDuration: DURATION_MS + 'ms' }"></i></div>
<div class="go" :class="{ gold: t.cls === 't2' }">{{ t.go }}</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onUnmounted, reactive } from "vue";
import { useRouter } from "vue-router";
/**
* WS 实时推送卡片(design/03 .ws-toast 落地,2026-10-09):替代 view-ui-plus Notice.info 临时卡片。
* 类型样式:0系统通知 t1(主色)🔔 / 1业务提醒 t2(金色)📋 / 2审批待办 t3(红)⏳;
* 8s 进度条自动消退,点击卡片或「去处理/查看」跳 /msg(列表口径由服务端按校过滤,本卡片只做提醒不承载数据权威)。
* 审批直批按钮(设计稿 approve-row)本期不做,待办仍进消息中心处理。
* 颜色用 var(--x, 回落值):App 目前未定义 --color-* 主题变量,回落取设计稿 :root 同值,未来接主题无需改这里。
*/
const DURATION_MS = 8000;
const LEAVE_MS = 280;
const router = useRouter();
interface ToastItem {
id: number;
cls: string;
ic: string;
name: string;
tm: string;
title: string;
content: string;
go: string;
leaving: boolean;
}
const toasts = reactive<ToastItem[]>([]);
let seq = 0;
const timers = new Map<number, ReturnType<typeof setTimeout>>();
/** 类型 → 卡片样式/图标/脚标文案(msgType 值域与 WsPushPayload 一致:0/1/2,缺省按系统通知) */
function typeSpec(msgType: number | null) {
switch (msgType) {
case 1:
return { cls: "t2", ic: "📋", go: "去处理 →" };
case 2:
return { cls: "t3", ic: "⏳", go: "查看详情 →" };
default:
return { cls: "t1", ic: "🔔", go: "查看全部消息 →" };
}
}
function timeText(frame: any) {
const sendTime: string = frame?.sendTime || "";
const hhmm = sendTime.length >= 16 ? sendTime.slice(11, 16) : "";
return `新消息 · ${hhmm || "刚刚"}`;
}
/** Header onNewMessage 入口:帧已过按校过滤,这里只负责呈现 */
function push(frame: any) {
const spec = typeSpec(frame ? frame.msgType : null);
const item: ToastItem = {
id: ++seq,
cls: spec.cls,
ic: spec.ic,
name: { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[frame?.msgType as 0 | 1 | 2] || "系统通知",
tm: timeText(frame),
title: frame?.title || "",
content: frame?.content || "",
go: spec.go,
leaving: false,
};
// 最多同屏 4 张:再来的挤掉最老一张,防长消息风暴糊屏
while (toasts.length >= 4) {
drop(toasts[0].id);
}
toasts.push(item);
timers.set(item.id, setTimeout(() => dismiss(item), DURATION_MS));
}
function dismiss(item: ToastItem) {
if (item.leaving) return;
item.leaving = true;
const t = timers.get(item.id);
if (t) clearTimeout(t);
timers.delete(item.id);
setTimeout(() => drop(item.id), LEAVE_MS);
}
function drop(id: number) {
const idx = toasts.findIndex(t => t.id === id);
if (idx >= 0) toasts.splice(idx, 1);
}
function goMsgCenter(item: ToastItem) {
dismiss(item);
router.push("/msg");
}
onUnmounted(() => {
timers.forEach(t => clearTimeout(t));
timers.clear();
});
defineExpose({ push });
</script>
<style scoped lang="scss">
.ws-toast-layer {
position: fixed;
right: 20px;
top: calc(var(--headerH, 70px) + 8px);
z-index: 1500;
display: flex;
flex-direction: column;
gap: 10px;
width: 352px;
pointer-events: none;
}
.ws-toast {
pointer-events: auto;
background: rgba(255, 255, 255, .97);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: var(--radius-m, 10px);
box-shadow: 0 12px 32px rgba(30, 40, 60, .18), 0 2px 6px rgba(30, 40, 60, .08);
border: 1px solid rgba(255, 255, 255, .8);
overflow: hidden;
border-left: 4px solid var(--color-primary, #5979ea);
animation: wsIn .42s cubic-bezier(.2, .9, .3, 1.2);
position: relative;
cursor: pointer;
&.t1 { border-left-color: var(--color-primary, #5979ea); }
&.t2 { border-left-color: var(--color-warning, #e9b954); }
&.t3 { border-left-color: var(--color-error, #e8684a); }
&.out { animation: wsOut .28s ease forwards; }
.wt {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 15px 9px;
.ic {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
}
.meta {
flex: 1;
min-width: 0;
.nm {
font-size: 14px;
font-weight: 600;
color: #252b33;
}
.tm {
font-size: 11.5px;
color: var(--text-weak, #b3b3b3);
margin-top: 3px;
}
}
.x {
color: #c0c6ce;
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
&:hover {
background: var(--bg-page, #f3f3f3);
color: #7a8088;
}
}
}
&.t1 .ic { background: var(--color-primary-weak, #eef2ff); color: var(--color-primary, #5979ea); }
&.t2 .ic { background: #fdf4e3; color: #c8922a; }
&.t3 .ic { background: #fdecea; color: var(--color-error, #e8684a); }
.wc {
padding: 0 15px 12px 55px;
font-size: 13px;
color: #5a6068;
line-height: 1.65;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.tt {
color: #2c3138;
font-weight: 500;
}
}
.wf {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 15px;
background: rgba(249, 250, 252, .9);
border-top: 1px solid var(--border-light, #eef0f3);
.progress {
flex: 1;
height: 3px;
background: #e6e9ee;
border-radius: 2px;
overflow: hidden;
i {
display: block;
height: 100%;
background: var(--color-primary, #5979ea);
border-radius: 2px;
animation-name: wsProg;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
}
.go {
font-size: 12px;
color: var(--color-primary, #5979ea);
font-weight: 500;
flex-shrink: 0;
&.gold { color: #c8922a; }
}
}
}
@keyframes wsIn {
from { opacity: 0; transform: translateX(36px) scale(.96); }
to { opacity: 1; transform: none; }
}
@keyframes wsOut {
to { opacity: 0; transform: translateX(36px) scale(.96); margin-bottom: -100px; }
}
@keyframes wsProg {
from { width: 100%; }
to { width: 0; }
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<div class="wg-evaluate" ref="refRoot">
<Skeleton
v-if="loading"
style="padding: 15px"
:loading="true"
:title="false"
:paragraph="{ rows: 4 }"
animated
/>
<ul v-else-if="list.length > 0" class="tpk-ul">
<li v-for="item in list" :key="item.id">
<a :href="courseDetailUrl(item.id)" target="_blank" rel="noopener noreferrer" :title="item.title">
<span class="title">{{ item.title }}</span>
<span class="sub">{{ item.lectureteaName }} | {{ item.subName }}</span>
<span class="date">{{ formatDate(item.lectureTime) }}</span>
</a>
</li>
</ul>
<div v-else class="no-data">暂无数据</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
import UserSettingApi from "@/api/UserSettingApi";
defineProps<{ widgetId: number }>();
const emit = defineEmits(["loaded"]);
const refRoot = ref<HTMLElement | null>(null);
const loading = ref(true);
const list = ref<any[]>([]);
let observer: ResizeObserver | null = null;
onMounted(async () => {
if (window.ResizeObserver && refRoot.value) {
observer = new ResizeObserver(reportHeight);
observer.observe(refRoot.value);
}
try {
const data: any = await UserSettingApi.getWidgetData("evaluate", "page=0&count=5");
list.value = (data && data.list) || [];
} catch (e) {
console.warn("[widget] evaluate 取数失败", e);
}
loading.value = false;
reportHeight();
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
function reportHeight() {
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
}
function formatDate(lectureTime: string) {
return (lectureTime || "").slice(0, 10);
}
/** 跳转链接由前端拼(口径同旧 evaluate_index.js getLinkUrl),候选池不再下发任何 html 地址 */
function courseDetailUrl(id: number) {
return window.location.origin + "/EvaluateCourese/front/#/courseDetail?id=" + id;
}
</script>
<style lang="scss" scoped>
.wg-evaluate {
padding: 6px 0 4px;
}
.tpk-ul {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0 15px;
padding: 6px 0;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: 0;
}
a {
display: flex;
align-items: center;
text-decoration: none;
}
}
.title {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 26px;
color: #333;
}
.sub {
flex: 0 0 auto;
font-size: 12px;
color: #bbb;
line-height: 26px;
margin-left: 10px;
}
.date {
flex: 0 0 auto;
font-size: 12px;
color: #666;
line-height: 26px;
margin-left: 10px;
}
}
.no-data {
min-height: 90px;
display: flex;
align-items: center;
justify-content: center;
color: #b9c2cc;
font-size: 13px;
}
</style>
+331
View File
@@ -0,0 +1,331 @@
<template>
<div class="wg-syllabus" ref="refRoot">
<Skeleton
v-if="loading"
style="padding: 15px"
:loading="true"
:title="false"
:paragraph="{ rows: 4 }"
animated
/>
<template v-else>
<!-- 班级切换器:挂进卡片 header(index.vue 埋的 wg-hdr-<id> 锚点);多班(教师任教/班主任、校领导全校)才显示 -->
<Teleport v-if="classes.length > 1" :to="'#wg-hdr-' + widgetId">
<div class="cls-dd" :class="{ open: ddOpen }">
<button type="button" class="cls-trigger" @click.stop="ddOpen = !ddOpen">
<span class="cls-name">{{ classLabel(currentClass) }}</span>
<svg class="caret" viewBox="0 0 1024 1024" width="10" height="10" aria-hidden="true">
<path fill="currentColor" d="M512 704 128 320l64-64 320 320 320-320 64 64z" />
</svg>
</button>
<div v-show="ddOpen" class="cls-panel">
<div
v-for="(cls, index) in classes"
:key="cls.classId"
class="cls-item"
:class="{ on: index === clsIdx }"
@click="pickClass(index)"
>
{{ classLabel(cls) }}
</div>
</div>
</div>
</Teleport>
<div v-if="slots.length > 0" class="table-wrap">
<!-- 超过 7 节(表头 44 + 7×44 = 352px)纵向滚动,表头 sticky 常驻 -->
<div class="tt-scroll">
<table class="tt">
<thead>
<tr>
<th>节</th>
<th v-for="day in dayCols" :key="day.index">{{ day.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="slot in slots" :key="slot.slotNo">
<td class="sec">{{ cnNum(slot.slotNo) }}</td>
<td v-for="day in dayCols" :key="day.index">
<span class="sub" :title="subjectOf(slot, day.index)">{{ subjectOf(slot, day.index) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 空态:模板内联 SVG,不依赖外部图片 / CSS 背景 -->
<div v-else class="wg-empty">
<svg width="112" height="112" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="#F2F3F5" />
<rect y="182.438" width="210" height="27.5625" fill="#F7F8FA" />
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC" />
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="#F2F3F5" />
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="#fff" />
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="#EAEDF1" />
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="#EAEDF1" />
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA" />
</svg>
<span class="txt">暂无课表数据</span>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import UserSettingApi from "@/api/UserSettingApi";
defineProps<{ widgetId: number }>();
const emit = defineEmits(["loaded"]);
/** 节次中文数字(下标 0 = 第 1 节) */
const CN_NUM = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
/** 星期名(下标 0~6 = 周一~周日,对齐 slots[].subjects 下标口径) */
const WEEK_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const refRoot = ref<HTMLElement | null>(null);
const loading = ref(true);
/** oapi data[]:每项一个班级(classId/className/gradeName/timetable.slots…) */
const classes = ref<any[]>([]);
const clsIdx = ref(0);
const ddOpen = ref(false);
let observer: ResizeObserver | null = null;
const currentClass = computed<any>(() => classes.value[clsIdx.value] || {});
/** 节次行:按 slotNo 升序(按接口实际返回渲染) */
const slots = computed<any[]>(() => {
const list = currentClass.value && currentClass.value.timetable && currentClass.value.timetable.slots;
if (!Array.isArray(list)) return [];
return [...list].sort((a, b) => ((a && a.slotNo) || 0) - ((b && b.slotNo) || 0));
});
/** 星期列:恒 周一~周五;周六/周日有课才追加 */
const dayCols = computed(() => {
const cols = [0, 1, 2, 3, 4];
if (slots.value.some(slot => subjectOf(slot, 5))) cols.push(5);
if (slots.value.some(slot => subjectOf(slot, 6))) cols.push(6);
return cols.map(index => ({ index, name: WEEK_NAMES[index] }));
});
/** 班级展示名 = 年级 + 班级(如「高一 1班」);无年级时退化为班级名 */
function classLabel(cls: any): string {
if (!cls) return "";
const grade = cls.gradeName ? String(cls.gradeName) : "";
const name = cls.className ? String(cls.className) : "";
return grade ? `${grade} ${name}` : name;
}
function subjectOf(slot: any, index: number): string {
const arr = slot && slot.subjects;
if (!Array.isArray(arr) || !arr[index]) return "";
return String(arr[index]);
}
function cnNum(slotNo: number): string {
return CN_NUM[slotNo - 1] || String(slotNo);
}
function pickClass(index: number) {
clsIdx.value = index;
ddOpen.value = false;
}
function closeDropdown() {
ddOpen.value = false;
}
onMounted(async () => {
document.addEventListener("click", closeDropdown);
if (window.ResizeObserver && refRoot.value) {
observer = new ResizeObserver(reportHeight);
observer.observe(refRoot.value);
}
try {
const data: any = await UserSettingApi.getWidgetData("syllabus", "");
classes.value = Array.isArray(data) ? data : [];
} catch (e) {
console.warn("[widget] syllabus 取数失败", e);
}
loading.value = false;
reportHeight();
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeDropdown);
if (observer) observer.disconnect();
});
function reportHeight() {
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
}
</script>
<style lang="scss" scoped>
.wg-syllabus {
padding: 0 0 10px;
}
/* ---------- 班级切换器(下拉;渲染在卡片 header 内,见 index.vue 的 wg-hdr-<id> 锚点) ---------- */
.cls-dd {
position: relative;
}
.cls-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
cursor: pointer;
font-family: inherit;
// header 是 700 粗体,切换器属次级信息 → 复位常规字重
font-weight: 400;
font-size: 13px;
color: #344449;
padding: 3px 8px;
border-radius: 6px;
line-height: 1.5;
&:hover {
background: #eef1f5;
}
.caret {
color: #98a2b0;
transition: transform 0.18s;
}
}
.cls-dd.open .caret {
transform: rotate(180deg);
}
.cls-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 132px;
max-height: 264px;
overflow-y: auto;
background: #fff;
border: 1px solid #e7eaee;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.14);
padding: 4px;
}
.cls-item {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
color: #344449;
cursor: pointer;
white-space: nowrap;
&:hover {
background: #f4f7ff;
}
&.on {
color: var(--color-primary, #5979ea);
background: var(--color-primary-weak, #eef2ff);
font-weight: 600;
}
}
/* ---------- 课表 ---------- */
/* ⚠️ 表格类名必须用私有名(tt):用 my-table 会命中全局 componets/_my_table.scss,
带进 #c7c7c7 竖线与 th 灰底 ⇒「竖线明显 / 表头与卡头糊成一片 / 横线被压得看不见」 */
.table-wrap {
padding: 8px 8px 10px;
}
/* 最多露出 7 节课(表头 44 + 7×44 = 352),再多则纵向滚动,表头 sticky 常驻 */
.tt-scroll {
max-height: 352px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #d5dbe3 transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d5dbe3;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.tt {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
th {
height: 44px;
font-size: 13px;
font-weight: 700;
color: #344449;
text-align: center;
// reset(meyerweb) 给 th/td 统一设了 vertical-align: baseline ⇒ 必须显式复位,
// 否则表头文字按基线对齐、偏上,视觉上像"贴住卡片 header"
vertical-align: middle;
border: 0;
// sticky + border-collapse 时 cell 的 border 会随滚动丢失 ⇒ 用 inset box-shadow 画分隔线
box-shadow: inset 0 -1px 0 #e9edf1;
position: sticky;
top: 0;
z-index: 1;
background: #fff;
}
th:first-child,
td:first-child {
width: 46px;
}
td {
height: 44px;
border-bottom: 1px solid #eef1f4;
padding: 4px 2px;
text-align: center;
font-size: 13px;
vertical-align: middle;
}
tr:last-child td {
border-bottom: 0;
}
td.sec {
color: #6e7c87;
font-weight: 700;
}
}
/* 科目文本:最多 2 行,超出以省略号收尾,title 悬停可看全文 */
.sub {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.35;
word-break: break-word;
}
/* ---------- 空态(模板内联 SVG,不依赖外部图片 / CSS 背景) ---------- */
.wg-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 132px;
padding: 16px 0 20px;
.txt {
font-size: 12px;
color: #98a2b0;
}
}
</style>
+743
View File
@@ -0,0 +1,743 @@
<template>
<div class="wg-weekplan" ref="refRoot">
<!-- ④ 「查看概览」:Teleport 进卡片 header 右侧(index.vue 埋的 #wg-hdr-<id> 锚点,
与课程表班级切换器同一机制);a 标签直跳子应用,加载中/空态都在位,不随周数据闪灭 -->
<Teleport v-if="overviewUrl" :to="'#wg-hdr-' + widgetId">
<a class="ov-link" :href="overviewUrl" target="_blank" rel="noopener">查看概览</a>
</Teleport>
<!-- 加载态:结构化骨架。各段与真实内容同构同高(标题 40 + 七日条 63 + 列表 171 + 备注区 66.6),
换周不再「先塌再弹」;方案 B 下加载/空周/有备注周三态恒等 ≈364.6px -->
<div v-if="loading" class="wk-skeleton" aria-hidden="true">
<div class="sk-title">
<i class="bar" style="width: 46%"></i>
<span class="sk-pager"><i></i><i></i></span>
</div>
<div class="sk-days">
<div class="sd on"><span class="w"></span><b><i></i></b></div>
<div class="sd"><span class="w"></span><b><i></i></b></div>
<div class="sd"><span class="w"></span><b><i></i></b></div>
<div class="sd"><span class="w"></span><b><i></i></b></div>
<div class="sd"><span class="w"></span><b><i></i></b></div>
<div class="sd"><span class="w"></span><b><i></i></b></div>
<div class="sd"><span class="w"></span><b><i></i></b></div>
</div>
<div class="sk-list">
<div class="sr"><div class="l"><span class="c"></span><span class="t"></span></div><div class="m"></div></div>
<div class="sr"><div class="l"><span class="c"></span><span class="t"></span></div><div class="m"></div></div>
<div class="sr"><div class="l"><span class="c"></span><span class="t"></span></div><div class="m"></div></div>
</div>
<div class="sk-remark-slot"><i class="b1"></i><i class="b2"></i></div>
</div>
<!-- 门控用 hasWeek(有 title 的周对象)而非 hasData:空周(hasData=false)
也必须保留标题行 + 翻页箭头,否则用户翻进空周后没有出口 -->
<template v-else-if="hasWeek">
<div class="wk-title">
<!-- 显示用简称(日期区间 + 第N周);接口全称(如「学校工作安排」)留在 title 里 hover 可见 -->
<span :title="titleFull">{{ titleStr }}</span>
<!-- 翻页:边界由响应 minWeek/maxWeek 驱动(缺省按 [0,0] 禁用两侧);越界 oapi 返回 code:2,前端先拦 -->
<span class="pager">
<i :class="{ disabled: !canPrev }" @click="onPrev">‹</i>
<i :class="{ disabled: !canNext }" @click="onNext">›</i>
</span>
</div>
<div v-if="days.length > 0" class="wk-days">
<div
v-for="(day, index) in days"
:key="day.date"
:class="['d', { 'is-on': index === currentIndex, has: hasWork(index), rest: day.dayType === 1, holiday: day.dayType === 2 }]"
@click="onDayClick(index)"
>
<span>{{ day.weekday }}</span>
<b>{{ dayText(day) }}</b>
</div>
</div>
<!-- 列表区固定高度(= 3 条 × --wk-row):超出滚动;空态(休/节假日/无安排)同高,卡片高度不跳 -->
<div class="wk-list-area">
<ul v-if="currentList.length > 0" class="wk-list">
<li v-for="(item, index) in currentList" :key="index">
<!-- 标题独占一行(右侧不再被时间占宽,长标题多显示约 8 字) -->
<span class="content" :title="item.contents">{{ item.contents }}</span>
<!-- 信息行恒渲染:地点 / 负责人 / 出席对象全空时落「-」,不留白(条目保持等高);
时间移到本行最右端;无时间不留位 -->
<div class="meta">
<span class="meta-txt">{{ metaOf(item) || "-" }}</span>
<span v-if="item.times" class="time" :class="{ 'is-cjk': isCjk(item.times) }">{{ item.times }}</span>
</div>
</li>
</ul>
<div v-else class="no-data">{{ currentDayLabel }}</div>
</div>
<!-- 备注区恒占位(min-height = 2 行折叠高 58.59px):无备注只留白、不出灰盒,
与骨架/空周/有备注周同高 ⇒ 任意切换零高度变化(方案 B)。
默认 2 行截断,溢出才给「展开」(折叠态按钮贴第二行末尾) -->
<div class="wk-remark-slot">
<div v-if="remarkText" class="wk-remark">
<div ref="refRemark" class="rk-txt" :class="{ expanded: remarkExpanded }">{{ remarkText }}</div>
<span v-if="remarkOverflow" class="rk-more" @click="toggleRemark">{{ remarkExpanded ? "收起" : "展开" }}</span>
</div>
</div>
</template>
<!-- 空态:模板内联 SVG,不依赖外部图片 / CSS 背景 -->
<div v-else class="wg-empty">
<svg width="112" height="112" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="#F2F3F5" />
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="#F2F3F5" />
<rect y="182.438" width="210" height="27.5625" fill="#F7F8FA" />
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC" />
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="#F2F3F5" />
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="#fff" />
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="#EAEDF1" />
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="#EAEDF1" />
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA" />
</svg>
<span class="txt">暂无数据</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
import UserSettingApi from "@/api/UserSettingApi";
defineProps<{ widgetId: number; overviewUrl?: string }>();
const emit = defineEmits(["loaded"]);
const refRoot = ref<HTMLElement | null>(null);
const refRemark = ref<HTMLElement | null>(null);
const loading = ref(true);
/**
* 接口 data:单个周对象(title/year/term/week/monday/days[]/remark/hasData/minWeek/maxWeek),非数组。
* 命名警示:形参 week(偏移量)曾与 ref week(响应体)同名遮蔽,严格模式下 `week.value=` 抛
* TypeError 且 catch 内再抛 ⇒ loading 永驻(v1 骨架卡死根因)⇒ ref 恒用 weekData,禁止再命名 week。
*/
const weekData = ref<any>({});
/** week 偏移:0=本周、-1=上一周、+1=下周(与数据字段 data.week 绝对周次同名不同义) */
const weekOffset = ref(0);
/** 合法偏移范围:每次响应刷新;老版本服务缺省时按 [0,0] 处理(宁可不翻,也不打越界 code:2) */
const minWeek = ref(0);
const maxWeek = ref(0);
const currentIndex = ref(0);
/** 备注展开态;remarkOverflow 只在折叠态测量(溢出才显示按钮) */
const remarkExpanded = ref(false);
const remarkOverflow = ref(false);
let observer: ResizeObserver | null = null;
const days = computed<any[]>(() => (Array.isArray(weekData.value.days) ? weekData.value.days : []));
/** 标题简称:日期区间 + 第N周(接口 title 往往是「××学校工作安排」这类长名,放到 hover) */
const titleStr = computed<string>(() => {
const list = days.value;
const week = Number(weekData.value.week);
const weekPart = Number.isFinite(week) && week > 0 ? `第${week}周` : "";
if (list.length >= 2) {
const range = `${monthDay(list[0])}~${monthDay(list[list.length - 1])}`;
return weekPart ? `${range} ${weekPart}` : range;
}
return weekPart || weekData.value.title || "";
});
/** hover 全称:接口 title,与简称相同则不挂(避免重复提示) */
const titleFull = computed<string>(() => {
const full = weekData.value.title || "";
return full === titleStr.value ? "" : full;
});
/**
* 有周对象 = titleStr 非空(含 hasData=false 的空周:后端仍回 title/days,只是 items 全空)。
* 翻页箭头必须随之保留,避免「翻进空周 → 箭头消失 → 无法返回」的死角;
* 真正无周对象(oapi 降级返回 {})才走大图标空态。
*/
const hasWeek = computed(() => titleStr.value !== "");
// trim:备注尾部换行/空格会多出一个空行,让「展开」在只有 2 行内容时也误判溢出
const remarkText = computed<string>(() => String(weekData.value.remark || "").trim());
const canPrev = computed(() => weekOffset.value > minWeek.value);
const canNext = computed(() => weekOffset.value < maxWeek.value);
const currentList = computed<any[]>(() => {
const day = days.value[currentIndex.value];
return day && Array.isArray(day.items) ? day.items : [];
});
/** 当天无安排时的占位文案:优先接口 dayLabel(节日名/无/休),否则兜「暂无工作安排」 */
const currentDayLabel = computed<string>(() => {
const day = days.value[currentIndex.value];
return (day && day.dayLabel) || "暂无工作安排";
});
onMounted(async () => {
if (window.ResizeObserver && refRoot.value) {
observer = new ResizeObserver(reportHeight);
observer.observe(refRoot.value);
}
await loadWeek(0);
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
async function loadWeek(week: number) {
weekOffset.value = week;
loading.value = true;
try {
const data: any = await UserSettingApi.getWidgetData("weekplan", "week=" + week);
weekData.value = data && typeof data === "object" ? data : {};
// 合法偏移范围:老版本服务不返回时按 0 处理(两侧禁用)
minWeek.value = Number.isFinite(Number(weekData.value.minWeek)) ? Number(weekData.value.minWeek) : 0;
maxWeek.value = Number.isFinite(Number(weekData.value.maxWeek)) ? Number(weekData.value.maxWeek) : 0;
if (week === 0) locateToday();
else currentIndex.value = 0; // 非本周默认落周一,不沿用「今天」下标
} catch (e) {
console.warn("[widget] weekplan 取数失败", e);
weekData.value = {};
} finally {
// finally 收尾:即使 catch 内再抛,loading 也必须落 false(v1 骨架卡死的直接机制)
loading.value = false;
// 换周/换数据后重置备注折叠态并重测溢出
remarkExpanded.value = false;
remarkOverflow.value = false;
nextTick(measureRemark);
reportHeight();
}
}
/** 今日定位:优先按 date 匹配今日 ISO(防跨周边界偏差),匹配不到退星期推算 */
function locateToday() {
const list = days.value;
if (list.length === 0) {
currentIndex.value = 0;
return;
}
const now = new Date();
const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const hit = list.findIndex(d => d && d.date === iso);
currentIndex.value = hit >= 0 ? hit : (now.getDay() + 6) % 7;
if (currentIndex.value >= list.length) currentIndex.value = 0;
}
function pad(n: number) {
return n < 10 ? "0" + n : "" + n;
}
function onPrev() {
if (!canPrev.value) return;
loadWeek(weekOffset.value - 1);
}
function onNext() {
if (!canNext.value) return;
loadWeek(weekOffset.value + 1);
}
function onDayClick(index: number) {
currentIndex.value = index;
}
function hasWork(index: number) {
const day = days.value[index];
return !!day && Array.isArray(day.items) && day.items.length > 0;
}
/** 日期条只显示「日」,不带月份:优先 date(yyyy-MM-dd),退化到 dateText 尾部数字 */
function dayText(day: any) {
if (!day) return "";
const date = typeof day.date === "string" ? day.date : "";
if (date.length >= 10) return date.slice(8, 10);
const text = String(day.dateText || "");
const hit = text.match(/(\d{1,2})\s*$/);
return hit ? hit[1].padStart(2, "0") : text;
}
/** M月D日(标题区间用;月/日去前导零) */
function monthDay(day: any) {
const date = day && typeof day.date === "string" ? day.date : "";
if (date.length >= 10) {
return `${Number(date.slice(5, 7))}月${Number(date.slice(8, 10))}日`;
}
return String((day && day.dateText) || "");
}
/** 次要信息行:地点 / 负责人 / 参与人员;三者全空时落「-」,不留白(条目保持等高) */
function metaOf(item: any) {
if (!item) return "";
const parts: string[] = [];
if (item.place) parts.push(item.place);
if (item.principal) parts.push(item.principal);
if (item.participant) parts.push(item.participant);
return parts.join(" · ");
}
/** 右侧时间文案是否含汉字(如「全天」):同字号下汉字字面高 ≈1.29 × 数字,降一档字号(.is-cjk) */
function isCjk(value: any) {
return /[\u4e00-\u9fa5]/.test(String(value || ""));
}
function toggleRemark() {
remarkExpanded.value = !remarkExpanded.value;
if (!remarkExpanded.value) nextTick(measureRemark);
reportHeight();
}
/** 备注溢出判定:折叠态下内容高度超过可视高度才算溢出(展开态保持按钮可见,不重测) */
function measureRemark() {
const el = refRemark.value;
if (!el || remarkExpanded.value) return;
remarkOverflow.value = el.scrollHeight > el.clientHeight + 2;
}
function reportHeight() {
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
}
</script>
<style lang="scss" scoped>
/* 主色(2026-10-16 指定):今日方块 / 七日条圆点 / 「查看概览」/「展开」/ 今日星期 */
$wk-primary: #5979ea;
$wk-primary-dark: #4864d8;
.wg-weekplan {
/* 一条安排的高度:内容行(15px × 1.5 = 22.5) + 信息行(12.5px × 1.5 = 18.75)
+ 上下 padding 12 + 分隔线 1 ≈ 56.25px ⇒ 取 57px 留余量。
有/无「地点·负责人·参与人」的条目都占这个高度,标题行始终对齐。 */
--wk-row: 57px;
/* 结构化骨架的灰条颜色 */
--wk-skel: rgba(190, 190, 190, 0.35);
padding: 6px 0 10px;
}
.wk-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 15px;
padding: 6px 0 10px;
font-size: 15px;
font-weight: 700;
color: #344449;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pager {
display: flex;
gap: 2px;
i {
font-style: normal;
font-size: 16px;
font-weight: 400;
color: #6e7c87;
/* 定尺寸 + flex 双向居中:行盒(24px)里字形贴基线,直接给背景会把字形顶偏 */
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
line-height: 1;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.16s, color 0.16s;
user-select: none;
&.disabled {
color: #d6dbe1;
cursor: default;
}
&:not(.disabled):hover {
background: #eef1f5;
color: #344449;
}
&:not(.disabled):active {
background: #e2e7ee;
}
}
}
}
.wk-days {
display: flex;
/* 下边距 6→8:删除「日常详情」行后补回原视觉间距(原来 6 与 tips 的 8 塌缩取 max=8) */
margin: 0 15px 8px;
gap: 4px;
.d {
flex: 1 1 0;
text-align: center;
font-size: 14px;
color: #6e7c87;
border-radius: 4px;
// 底部留出圆点位置:整周无安排时也占位,日期条高度恒定
padding: 4px 0 11px;
cursor: pointer;
// 星期数:块级行盒 + 下留 3px —— 把今日方块与圆点整体让下去,数字本身位置不动
// (方块 28px 上下居中于数字,不加留白时上沿贴「星期数」4.1px、下沿贴圆点 1.5px)
// ⚠️ 必须用 padding-bottom 而非 margin-bottom:后者会与 b 的 margin-top 折叠
//(max(正) + min(负)),今日格的 −1px 与其余格的 2px 会走出不同结果 ⇒ 今日数字偏低 1.75px
> span {
display: block;
line-height: 21px;
padding-bottom: 3px;
}
b {
display: block;
font-size: 15px;
color: #344449;
margin-top: 2px;
font-weight: 600;
// 圆点改锚数字块(不再锚格子):格子被今日格 28px 方块拉高时圆点不再跟着沉
position: relative;
}
// 圆点绝对定位在数字块下方 6px(bottom:-11px ⇒ 点中心 = b 底边 + 9,与留白一起把圆点让到方块下方)
b::after {
content: "";
position: absolute;
left: 50%;
bottom: -11px;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: $wk-primary;
visibility: hidden;
}
&.has b::after {
visibility: visible;
}
// 休息日(周六 / 周日)与法定节假日:日期数字同档弱化灰(节假日不再标红)
&.rest b,
&.holiday b {
color: #98a2b0;
}
// 今日:蓝底白字方块(置最后,压过 rest / holiday 的日期字色)
// 28px 行盒会把数字中心相对 22.5px 行盒下移 5.5px(canvas 字体度量实测墨迹中心
// 11.25 → 14),margin-top 取 −1px(精确 −0.75 取整)补回,数字与其余 6 天同基线
&.is-on {
color: $wk-primary;
b {
display: block;
width: 28px;
height: 28px;
margin: -1px auto 0;
line-height: 28px;
background: $wk-primary;
color: #fff;
border-radius: 4px;
}
// 方块比数字行盒高 5.5px,圆点随之低 2.5px ⇒ 上收 2.5px,七格圆点同线
b::after {
bottom: -8.5px;
}
}
}
}
/* ④ 「查看概览」:渲染在卡片 header 右侧(Teleport 到 index.vue 的 #wg-hdr-<id> 锚点),
样式复刻课程表班级切换器 .cls-trigger 一族;a 标签直跳、无浮层 */
.ov-link {
display: inline-flex;
align-items: center;
font-family: inherit;
font-size: 13px;
font-weight: 400;
color: #344449;
text-decoration: none;
padding: 3px 8px;
border-radius: 6px;
line-height: 1.5;
transition: background-color 0.15s, color 0.15s;
&:hover {
background: #eef1f5;
color: $wk-primary;
}
}
/* 列表区:固定高度 = 3 条(3 × --wk-row,即 171px);多条滚动、空态占位同高,
避免切到「休/节假日」时卡片高度跳变 */
.wk-list-area {
height: calc(var(--wk-row) * 3);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d3d8de;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.wk-list {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0 15px;
// 条目等高:有/无「地点·负责人·参与人」都占一行高度(无信息落「-」占位,不留白),
// 保证标题行纵向对齐;overflow 兜住固定高度可能带来的亚像素裁切
height: var(--wk-row);
overflow: hidden;
padding: 6px 0;
border-bottom: 1px solid #f2f4f7;
color: #344449;
&:last-child {
border-bottom: 0;
}
}
// 标题独占一行(时间已移到信息行):14px,右侧不再被时间占宽
.content {
display: block;
font-size: 14px;
color: #344449;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 信息行:地点 / 负责人 / 参与人 在左,时间贴最右端;无时间时信息文本照常占满
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 2px;
font-size: 12.5px;
color: #98a2b0;
}
.meta-txt {
flex: 1 1 auto;
// ⚠️ 必须显式 min-width:0:flex 项默认 min-width:auto,遇 nowrap 长文本
// 会以 max-content 为下限而不收缩 ⇒ text-overflow 永不触发
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex: 0 0 auto;
color: #6e7c87;
// 汉字型时间(全天/放假)降一档:同字号下汉字字面高 ≈1.29 × 数字(15px 实测 18px vs 14px)
&.is-cjk {
font-size: 13px;
}
}
}
/* ---------- 加载态:结构化骨架(与内容逐段同构同高,换周不塌不跳) ----------
标题 40 + 七日条 63 + 列表 171 + 备注区 66.59 + 根部 padding 16 ≈ 364.6px,
与「空周内容」「有备注周内容」严格相等(方案 B)。注意根部勿再加 padding */
.wk-skeleton {
.sk-title {
margin: 0 15px;
padding: 6px 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
.bar {
height: 16px;
width: 46%;
border-radius: 4px;
background: var(--wk-skel);
}
// font-size 16 撑出与真实 pager 相同的 24px 行盒(标题段总高 40 的关键)
.sk-pager {
font-size: 16px;
i {
display: inline-block;
width: 10px;
height: 16px;
margin-left: 12px;
border-radius: 2px;
background: var(--wk-skel);
}
}
}
.sk-days {
display: flex;
margin: 0 15px 8px;
gap: 4px;
.sd {
flex: 1 1 0;
text-align: center;
font-size: 14px;
padding: 4px 0 11px;
// 星期条:外框 24px 与真实星期数同高(21px 行盒 + 3px 下留白),条本身 10px 居上
// ⚠️ 内层条的定位必须用父级 padding-top,不能用子元素 margin-top:
// 父级无 padding/border 时子元素上边距会「塌」到父级外面,凭空多出 5.5px 高度
.w {
display: block;
width: 14px;
height: 24px;
margin: 0 auto;
padding-top: 5.5px;
&::before {
content: "";
display: block;
height: 10px;
border-radius: 3px;
background: var(--wk-skel);
}
}
b {
display: block;
font-size: 15px;
margin-top: 2px;
i {
display: inline-block;
width: 20px;
height: 16px;
border-radius: 3px;
background: var(--wk-skel);
}
}
// 今日格:与真实 .d.is-on b 同几何(28px 方块 + margin-top −1)
&.on b {
width: 28px;
height: 28px;
margin: -1px auto 0;
i {
width: 28px;
height: 28px;
border-radius: 4px;
background: $wk-primary;
opacity: 0.55;
}
}
}
}
.sk-list {
height: calc(var(--wk-row) * 3);
.sr {
height: var(--wk-row);
margin: 0 15px;
padding: 6px 0;
border-bottom: 1px solid #f2f4f7;
&:last-child {
border-bottom: 0;
}
.l {
height: 21px;
display: flex;
align-items: center;
gap: 10px;
.c {
height: 14px;
width: 58%;
border-radius: 4px;
background: var(--wk-skel);
}
.t {
height: 14px;
width: 64px;
border-radius: 4px;
background: var(--wk-skel);
}
}
.m {
margin-top: 2px;
height: 10px;
width: 32%;
border-radius: 4px;
background: var(--wk-skel);
}
}
}
// 备注区占位:与 .wk-remark-slot 同高(min-height 58.59,无 padding,盒尺寸与全局 border-box 无关)
.sk-remark-slot {
margin: 8px 15px 0;
min-height: 58.59px;
border-radius: 6px;
background: #f7f8fa;
border-top: 1px solid #eef1f5;
i {
display: block;
height: 12px;
border-radius: 4px;
background: var(--wk-skel);
&.b1 {
width: 78%;
margin: 11px 10px 0;
}
&.b2 {
width: 56%;
margin: 9px 10px 0;
}
}
}
}
/* 备注区恒占位(方案 B):min-height = 2 行折叠高 58.59px。
无备注时只留白、不出灰盒;有备注时灰盒至少填满该区(1 行备注也不缩)。
⇒ 加载 / 空周 / 有备注周三态同高,任意切换零高度变化 */
.wk-remark-slot {
margin: 8px 15px 0;
min-height: 58.59px;
}
/* 备注:与「安排数据」区分——上方一条分割线 + 浅底块,跟白色列表拉开层次 */
.wk-remark {
position: relative;
padding: 8px 10px;
background: #f7f8fa;
border-top: 1px solid #eef1f5;
border-radius: 6px;
font-size: 13px;
color: #5b6672;
min-height: 58.59px;
.rk-txt {
line-height: 1.6;
// 备注可能是含换行的多行文本:pre-line 保留换行;折叠态截断 2 行
white-space: pre-line;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
// 右侧恒留 34px 给按钮:折叠态第二行以省略号收尾,展开态最后一行绕开按钮
padding-right: 34px;
&.expanded {
display: block;
overflow: visible;
}
}
// 「展开 / 收起」恒贴正文最后一行的右端(折叠=第二行、展开=最后一行),不另起一行
.rk-more {
position: absolute;
right: 10px;
bottom: 8px;
// 与备注块同底色,压住可能蹭到的正文尾部
background: #f7f8fa;
font-size: 13px;
color: $wk-primary;
cursor: pointer;
user-select: none;
&:hover {
color: $wk-primary-dark;
}
}
}
.no-data {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #b9c2cc;
font-size: 14px;
}
/* ---------- 空态(模板内联 SVG,不依赖外部图片 / CSS 背景) ---------- */
.wg-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 132px;
padding: 16px 0 20px;
.txt {
font-size: 12px;
color: #98a2b0;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { defineAsyncComponent } from "vue";
/** 组件键 → 内置组件。新增小组件只改这里 + widget_catalog 插一行。 */
export const widgetRegistry: Record<string, any> = {
evaluate: defineAsyncComponent(() => import("./EvaluateWidget.vue")),
syllabus: defineAsyncComponent(() => import("./SyllabusWidget.vue")),
weekplan: defineAsyncComponent(() => import("./WeekplanWidget.vue")),
};
+34 -1
View File
@@ -27,7 +27,12 @@ import AppHeader from "@/components/AppHeader.vue";
import UserInfoModal from "@/components/UserInfoModal.vue";
import mittBus from "@/utils/mitt";
/** 命令面板取证用空壳路由:只需 route.name === "apps" 让 Header 渲染放大镜,
* 不挂真实 AppList(避免本机无后端时 getUserApp 失败污染控制台) */
const CmdEmptyShell = defineComponent({ setup: () => () => h("div", { style: "height:100%" }) });
const harnessRoutes: any[] = [
{ path: "/apps", name: "apps", component: CmdEmptyShell, meta: { title: "应用" } },
{ path: "/msg", name: "Msg", component: MsgPage, meta: { title: "消息" } },
{ path: "/settings", name: "Settings", component: SettingsPage, meta: { title: "设置" } },
{ path: "/theme-preview", name: "ThemePreview", component: ThemePreviewPage, meta: { title: "主题预览" } },
@@ -73,12 +78,18 @@ function applyMockState() {
],
curUserAppList: [
{
id: 1,
name: "常用应用",
apps: [
{ appKey: "100019", appCode: "flow", name: "流程中心" },
{ appKey: "100617", appCode: "notice", name: "通知公告" },
{ appKey: "100611", appCode: "leave", name: "请假" },
],
},
{
id: 2,
name: "教学应用",
apps: [{ appKey: "100611", appCode: "leave", name: "请假" }],
},
],
unreadCount: 5,
curUserSettingInfo: {
@@ -258,3 +269,25 @@ function runAssert(attempt: number) {
document.title = "HARNESS-READY";
}
setTimeout(() => runAssert(0), 1500);
// ===== 命令面板取证:?cmd=1[&q=课] 自动点开放大镜并可选输入;配合 #/apps =====
if (params.get("cmd") === "1") {
setTimeout(() => {
const btn = document.querySelector(".operate-container .icon-box") as HTMLElement | null;
btn?.click();
const q = params.get("q");
setTimeout(
() => {
if (q) {
const inp = document.querySelector(".app-cmdk-hd input") as HTMLInputElement | null;
if (inp) {
inp.value = q;
inp.dispatchEvent(new Event("input"));
}
}
setTimeout(() => ((window as any).__CMDK_READY__ = true), 600);
},
q ? 500 : 600
);
}, 900);
}
-14
View File
@@ -7,8 +7,6 @@ import LightApp from "@/views/LightApp.vue";
import Stats from "@/views/Stats.vue";
import StatsRelay from "@/views/StatsRelay/index.vue";
import TeacherCopilot from "@/views/TeacherCopilot.vue";
import SysSetting from "@/views/SysSetting.vue";
import SysTempManage from "@/views/SysTempManage.vue";
const routes: RouteRecordRaw[] = [
{
@@ -59,18 +57,6 @@ const routes: RouteRecordRaw[] = [
component: TeacherCopilot,
meta: { title: "教师助手" },
},
{
path: "/syssetting",
name: "SysSetting",
component: SysSetting,
meta: { title: "系统设置" },
},
{
path: "/syssetting/tempmanage/:id",
name: "SysTempManage",
component: SysTempManage,
meta: { title: "模板管理" },
},
{
path: "/msg",
name: "Msg",
+8 -1
View File
@@ -12,6 +12,8 @@ export interface IdefaultStore {
curUserSettingInfo: UserSettingInfo;
curUserMenuList: Array<any>; //当前角色菜单
curUserAppList: Array<any>; //当前角色所有应用
/** 应用列表加载态(AppList 拉取时写入,Header 命令面板共享同一状态;2026-10-08) */
appListLoading: boolean;
curRoleUid: string | null; //当前角色uid
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加;Header 角标与 /msg 页左栏同源) */
unreadCount: number;
@@ -53,6 +55,7 @@ export const useDefaultStore = defineStore("defaultStore", {
curUserMenuList: [],
curRoleUid: null,
unreadCount: 0,
appListLoading: false,
};
},
actions: {
@@ -74,6 +77,9 @@ export const useDefaultStore = defineStore("defaultStore", {
setCurUserAppList(apps: any[]) {
this.curUserAppList = apps;
},
setAppListLoading(v: boolean) {
this.appListLoading = v;
},
setCurUserMenuList(menus: any[]) {
this.curUserMenuList = menus;
},
@@ -121,7 +127,8 @@ export const useDefaultStore = defineStore("defaultStore", {
getWidgetAppIds() {
const { widgetAppIds: appIds, widgetAppList: appList } = this.curUserSettingInfo;
let checkIds: number[] = [];
if (appIds == null) checkIds = appList.map(x => x.id);
// getWidgetAppList 的比较器按 appIds 降序渲染(新添加置顶),故未配置态需预反转,才与 widget_catalog.sort 正序一致
if (appIds == null) checkIds = appList.map(x => x.id).reverse();
else if (appIds.length == 0) checkIds = [];
else checkIds = appIds.split(",").map(x => parseInt(x));
return checkIds;
+34
View File
@@ -51,6 +51,40 @@ export interface WidgetPositionResp extends WidgetPosition {
schId?: number;
}
/** 小组件候选池目录行(widget_catalog + 管理端视角附加字段) */
export interface WidgetCatalog {
id?: number;
/** 组件键:内置 = 前端 widgetRegistry 的键;自定义 = 自定义标识 */
code: string;
/** 显示名(校管视角已按「校级别名 > 角色命名 > 名称」合成) */
name: string;
/** 1 = 前端内置组件 2 = 自定义 HTML 片段 */
compType: number;
content?: string | null;
dataCode?: string | null;
/** 下发范围:0 = 全部学校 1 = 白名单 2 = 黑名单 */
scopeType?: number;
/** 点名的学校 id 列表(scopeType=1/2 时有效) */
scopeSchIds?: number[];
/** 可见角色:逗号分隔 RoleTypeEnum;NULL/空 = 全角色 */
roleTypes?: string | null;
/** 角色专属命名 JSON 文本:{"2":"全校课表"} */
roleAlias?: string | null;
/** 校管视角:本校别名 */
alias?: string | null;
/** 校管视角:超管下发的全局名称 */
originalName?: string | null;
/** 校管视角:本校是否已有覆盖行 */
covered?: boolean;
/** 外跳入口:目标子应用 code(应用注册表 app_code),NULL/空 = 不显示入口 */
linkAppCode?: string | null;
/** 外跳入口:子应用内路由(如 /weekplan);绝对地址由后端按注册表 pc_url 解析 */
linkPath?: string | null;
sort?: number;
enabled?: number;
updateTime?: string | null;
}
export interface BschTransferRoleInfo {
roleInfo: RoleInfo;
ticket: string;
+15
View File
@@ -36,6 +36,21 @@ export function bschLinkPage(options: LinkPageOptions) {
}
}
/** 打开应用页面(与 AppList.vue 点击应用图标同一条路径):
* 默认新窗口打开;命中 iframe 白名单($conf.ifameAppIdArr)则走站内 iframe(带 Header)。 */
export function openAppPage(app: any, ifameAppIdArr: number[] = []) {
const options: LinkPageOptions = {
url: app.webAddr,
type: LinkOpenTypeEnum.Open,
title: app.name,
};
if (ifameAppIdArr.includes(app.id)) {
options.type = LinkOpenTypeEnum.IFrame;
options.isHeader = true;
}
bschLinkPage(options);
}
export function isTickeRoterByName(name: string) {
return ["BschAppDetails", "AppDetails"].includes(name);
}
-1
View File
@@ -11,7 +11,6 @@ type MittType<T = any> = {
openBschIframePage: IframePageParmas; //打开iframe页面
showLightAppModal: LightAppModal; //打开lightapp模态框
searchReset: void; //重置搜索框
searchChange: string | null;
};
// 类型
+3 -3
View File
@@ -1,11 +1,11 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261006-2013-75c1d10),勿手改。
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261011-1332-246e1e3),勿手改。
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
*/
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
/** 定稿默认档:blue + side-light + grad-on + 侧栏跟随(2026-10-11 翻转:什么都不配=浅色,深色需主动开启) */
export interface ThemeState {
theme: ThemeName;
/** 侧栏深色(false=浅色档) */
@@ -30,7 +30,7 @@ export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
export const DEFAULT_THEME_STATE: ThemeState = {
theme: "blue",
sideDeep: true,
sideDeep: false,
gradOn: true,
sideFollow: true
};
+60
View File
@@ -0,0 +1,60 @@
import conf from "@/assets/config/host.config";
/**
* 自定义 HTML 片段包装(方案 §5.2):门户拼两段注入,顺序不能合并成一段——
* ① 取数桥必须在 head 且早于片段自身脚本(否则片段里 yhWidget 为 undefined、内容永远空白);
* ② 高度上报必须在片段内容之后。管理员只贴正文,不需要自己写取数和上报。
*/
/** 平台统一信封 {code,msg,data}:片段用原生 fetch,需自行解出 data 业务体 */
const BRIDGE =
"window.yhWidget={getData:function(params){" +
"return fetch(YH_WIDGET_API+'?code='+YH_WIDGET_CODE+'&params='+encodeURIComponent(params||'')," +
"{credentials:'include',headers:{yschapirequest:'1'}})" +
".then(function(r){return r.json()})" +
".then(function(j){return j&&j.data!==undefined?j.data:j});}};";
/**
* ② 高度上报:先立即报一次(门户据此关掉骨架、把 iframe 从 display:none 放出来),
* 之后 ResizeObserver 触发节流补报。节流必须用 setTimeout 而不能用 rAF ——
* iframe 隐藏期间子文档不出动画帧,rAF 回调永不回来,会把节流标志永久占住、后续上报全被吞
* (实测帧高卡在 min-height,内容撑出内层滚动条)。
*/
const REPORTER =
"(function(){var timer=0;" +
"function send(){try{parent.postMessage(JSON.stringify({id:YH_WIDGET_ID," +
"height:document.documentElement.scrollHeight}),'*')}catch(e){}}" +
"function up(){if(timer)return;timer=setTimeout(function(){timer=0;send()},80)}" +
"if(window.ResizeObserver){try{new ResizeObserver(up).observe(document.body)}catch(e){}}" +
"window.addEventListener('load',up);send();setTimeout(up,300);setTimeout(up,1200);})();";
const RESET =
"html,body{margin:0;padding:0;background:transparent;color:#344449;font-size:13px;line-height:1.5;" +
'font-family:"Helvetica Neue",Helvetica,"PingFang SC","Microsoft YaHei",Arial,sans-serif;}';
/** 闭合标签必须拆开拼:否则提前闭合门户自身的 <script> */
function scriptTag(code: string) {
return "<" + "script>" + code + "<" + "/script>";
}
export function wrapFragment(html: string, id: number, dataCode: string): string {
const apiUrl = JSON.stringify(`${conf.serviceHost}/api/getWidgetData`);
const code = JSON.stringify(encodeURIComponent(dataCode || ""));
const widgetId = String(Number(id) || 0);
const bridge = scriptTag(
BRIDGE.replace(/YH_WIDGET_API/g, () => apiUrl)
.replace(/YH_WIDGET_CODE/g, () => code)
);
const reporter = scriptTag(REPORTER.replace(/YH_WIDGET_ID/g, () => widgetId));
return (
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
"<style>" +
RESET +
"</style>" +
bridge +
"</head><body>" +
(html || "") +
reporter +
"</body></html>"
);
}
+13 -74
View File
@@ -1,7 +1,7 @@
<template>
<div class="app-list-wrapper base-mask">
<vuescroll :ops="scrollOps">
<template v-if="!isSearch" v-for="(item, index) in appList">
<template v-for="(item, index) in appList">
<h3 class="app-type-box">{{ item.name }}</h3>
<div
:class="{
@@ -29,7 +29,7 @@
},
]"
>
<a @click="linkPage(citem.webAddr, citem)">
<a @click="linkPage(citem)">
<AppIcon v-if="vectorIconOf(citem)" :name="vectorIconOf(citem)" scene="tile"
:tint="citem.iconColor" />
<img v-else :src="$utils.appIconPath(citem.icon)" />
@@ -39,42 +39,6 @@
</template>
</div>
</template>
<template v-if="!!isSearch">
<div class="app-list-box" v-if="searchAppList.length > 0">
<template v-for="item in searchAppList">
<ContextMenu
class="app-container app-container__mask"
:title="item.name"
@on-change="onMenuChange"
@select="onMenuSel"
:menu="[
{
label: '添加到桌面',
appId: item.id,
id: '1',
},
{
label: '新窗口打开',
appId: item.id,
url: item.webAddr,
id: '2',
},
]"
>
<a @click="linkPage(item.webAddr, item)">
<AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:tint="item.iconColor" />
<img v-else :src="requireImg('defappicon.png')"
v-real-img="$utils.appIconPath(item.icon)" />
<div class="app-name">{{ item.name }}</div>
</a>
</ContextMenu>
</template>
</div>
<div class="no-search-box" v-else>
<img :src="requireImg('no-search-bg.png')" alt="" />
</div>
</template>
</vuescroll>
</div>
</template>
@@ -86,14 +50,12 @@ import vuescroll from "vuescroll";
import { ref, onMounted, onUnmounted, computed, watch } from "vue";
import BaseApi from "@/api/BaseApi";
import { useDefaultStore } from "@/store";
import { requireImg } from "@/utils/hook/useImg";
import mittBus from "@/utils/mitt";
import { useRoute, useRouter } from "vue-router";
import { BschLoadTypeEnum, DesktopAppTypeEnum, LinkOpenTypeEnum } from "@/types";
import { flatten } from "lodash";
import UserSettingApi from "@/api/UserSettingApi";
import { Message } from "view-ui-plus";
import { bschLinkPage } from "@/utils/bschLinkPage";
import { bschLinkPage, openAppPage } from "@/utils/bschLinkPage";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { appiconKeyOf } from "@/constants/appIconRules";
@@ -120,15 +82,8 @@ const router = useRouter();
const store = useDefaultStore();
const { $conf, $utils } = useCurrentInstance();
const searchAppText = ref<string | null>(null),
appList = ref<any[]>([]);
const appList = ref<any[]>([]);
const searchAppList = computed(() => {
const allAppList = flatten(appList.value.map(x => x.apps));
return allAppList.filter(x => x.name.indexOf(searchAppText.value) !== -1);
});
const isSearch = computed(() => !!searchAppText.value);
const compMenuId = computed(() => {
let menuList = store.curUserMenuList.find(x => x.url == route.path);
return menuList ? menuList.id : -1;
@@ -137,20 +92,23 @@ const compMenuId = computed(() => {
onMounted(() => {
reloadAppData();
mittBus.emit("searchReset");
mittBus.on("searchChange", val => {
searchAppText.value = val;
});
});
onUnmounted(() => {
mittBus.emit("searchReset");
mittBus.off("searchChange");
});
watch(() => store.curRoleUid, reloadAppData);
async function reloadAppData() {
store.setAppListLoading(true);
try {
const list = await BaseApi.getUserApp(store.getCurUserId, store.getCurSchId);
appList.value = list.filter(x => x.apps.length > 0);
// 写 store:Header 搜索命令面板与后台 WS 通知的应用名反查共用这一份(2026-10-08)
store.setCurUserAppList(appList.value);
} finally {
store.setAppListLoading(false);
}
}
function onMenuChange(curDom: any, state: boolean) {
@@ -185,20 +143,8 @@ async function onLightAppSaveDesktop(id: number) {
}
}
function linkPage(url: string, item: any) {
let options: Parameters<typeof bschLinkPage>[0];
options = {
url,
type: LinkOpenTypeEnum.Open,
title: item.name,
};
if ($conf.ifameAppIdArr.includes(item.id)) {
options.type = LinkOpenTypeEnum.IFrame;
options.isHeader = true;
}
bschLinkPage(options);
function linkPage(item: any) {
openAppPage(item, $conf.ifameAppIdArr);
}
</script>
@@ -242,11 +188,4 @@ function linkPage(url: string, item: any) {
flex-wrap: wrap;
}
}
.no-search-box {
text-align: center;
position: relative;
top: -50%;
-webkit-transform: translateY(40%);
transform: translateY(40%);
}
</style>
+1118 -463
View File
File diff suppressed because it is too large Load Diff
-768
View File
@@ -1,768 +0,0 @@
<template>
<div class="syssetting-wrap fix" v-if="isAdmin">
<!-- 左侧机构树(仅超管) -->
<div v-if="isSuperAdmin" class="left-box fl">
<h4 class="tree-title">机构</h4>
<Tree :data="treeData" select-node @on-select-change="onTreeSelect" />
</div>
<div :class="['right-box', isSuperAdmin ? 'ml-315' : '']">
<!-- 面包屑:仅超管展示当前选中的机构路径 -->
<Breadcrumb v-if="isSuperAdmin" class="sch-crumb">
<BreadcrumbItem v-for="(n, i) in currentSchPath" :key="n.id">
<span :class="['crumb-item', i === currentSchPath.length - 1 ? 'is-last' : '']">{{ n.title }}</span>
</BreadcrumbItem>
</Breadcrumb>
<Tabs v-model="tabIndex" type="card" class="syssetting-tabs">
<TabPane label="基础设置" name="basic">
<div class="basic-wrap">
<!-- Logo -->
<div class="setting-item">
<h4 class="item-title">
学校Logo
<span class="tip">(单张,上传即覆盖;推荐尺寸 70x70)</span>
</h4>
<div class="logo-uploader">
<ul class="logo-ul">
<li v-for="(item, index) in logoList" :key="item.id">
<label :class="['badge', item.type == 0 ? 'sys' : 'sch']">
{{ item.type == 0 ? "系统" : "校" }}
</label>
<img :src="item.url" alt="" />
<div class="operate" v-if="item.isEdit">
<a href="javascript:;" class="del" @click="onDeleteLogo(item)">删除</a>
</div>
</li>
<li class="add-cell">
<Upload
class="logo-upload"
:on-success="logoSuccess"
:show-upload-list="false"
:action="logoUploadAction"
accept="image/jpeg, image/jpg, image/png, image/gif, image/webp"
:with-credentials="true"
:headers="uploadHeaders"
>
<div class="add-box add-box--logo">
<SvgIcon name="upload-add" color="#939393" style="width: 22px; height: 22px" />
<p>上传Logo</p>
</div>
</Upload>
</li>
</ul>
</div>
</div>
<!-- 壁纸 -->
<div class="setting-item">
<h4 class="item-title">
壁纸
<span class="tip">(多张,与 Header「设置壁纸」弹窗数据互通;推荐尺寸 1920x1200)</span>
</h4>
<ul class="wallpaper-ul">
<li v-for="(item, index) in wallpaperList" :key="item.id">
<label :class="['badge', item.type == 0 ? 'sys' : 'sch']">
{{ item.type == 0 ? "系统" : "校" }}
</label>
<img :src="item.url" alt="" />
<div class="operate" v-if="item.isEdit">
<a href="javascript:;" class="del" @click="onDeleteWallpaper(item)">删除</a>
</div>
</li>
<li class="uploader">
<Upload
class="add-wrap tc"
:on-success="wallpaperSuccess"
:show-upload-list="false"
:action="wallpaperUploadAction"
accept="image/jpeg, image/jpg, image/png"
:with-credentials="true"
:headers="uploadHeaders"
>
<div class="add-box add-box--wallpaper">
<SvgIcon name="upload-add" color="#939393" style="width: 28px; height: 28px" />
<p>点击上传图片</p>
</div>
</Upload>
</li>
</ul>
</div>
</div>
</TabPane>
<TabPane label="应用管理" name="app">
<div class="app-manage">
<Tabs v-model="roleTypeTab" @on-click="onRoleTypeChange">
<TabPane :label="item.v" :name="item.k + ''" v-for="item in roleTypeList" :key="item.k" />
<template #extra>
<Button class="fl" size="small" type="primary" @click="showCategory()">新增应用</Button>
</template>
</Tabs>
<div class="search-wrap fix">
<Input
placeholder="输入关键字搜索"
class="fr"
search
suffix="ios-search"
style="width: 200px"
@on-search="onSearch"
v-model="dataParmas.name"
/>
</div>
<TablePage
:table-height="tableHeight"
:data-api="tableDataApi"
:columns="columns"
:page-params="pageParams"
:auto-load="true"
ref="refTablePage"
/>
<!-- 新增/编辑应用 -->
<Modal
v-model="isModal"
:mask-closable="false"
width="600"
:title="currentId == null ? '新增应用' : '编辑应用'"
>
<Form ref="refFormData" class="mt-8 mr-24" :model="formData" :rules="ruleValidate" :label-width="120">
<FormItem label="应用名称:" prop="name">
<Input placeholder="请输入应用名称" v-model="formData.name" />
</FormItem>
<FormItem label="接口地址:" prop="apiUrl">
<Input placeholder="请输入接口地址" v-model="formData.apiUrl" />
</FormItem>
<FormItem label="接口参数:" prop="paramsKey">
<ul class="parmas-ul">
<li class="fix mb-12" v-for="(item, index) in parmasList" :key="index">
<span class="ivu-form-item-label">参数名称</span>
<Input class="fl mr-12" v-model="item.k" style="width: 80px" />
<template v-if="item.type == 1">
<span class="ivu-form-item-label">参数类型</span>
<span>userId</span>
</template>
<template v-else-if="item.type == 2">
<span class="ivu-form-item-label">参数类型</span>
<span>学校Id</span>
</template>
<template v-else>
<span class="ivu-form-item-label">参数定值</span>
<Input class="fl mr-12" style="width: 80px" v-model="item.v" />
</template>
<Button
v-if="item.type != 1"
type="error"
ghost
icon="md-remove"
class="ml-12"
@click="parmasList.splice(index, 1)"
></Button>
</li>
</ul>
<div class="fix">
<Button class="fl" @click="parmasList.push({ k: '', v: '', type: 3 })">添加固定参数</Button>
<Button
v-if="!!currentId"
class="ml-8"
@click="parmasList.push({ k: 'schId', v: '', type: 2 })"
>
添加SchId参数
</Button>
</div>
</FormItem>
<FormItem label="数据解析:" prop="dataKey">
<Input placeholder="请输入数据字段" v-model="formData.dataKey" />
</FormItem>
</Form>
<template #footer>
<Button type="primary" :loading="isSubmitLoading" @click="onEditSave">提交</Button>
<Button @click="onReset">取消</Button>
</template>
</Modal>
</div>
</TabPane>
</Tabs>
</div>
</div>
<div class="no-permission" v-else>无权限访问</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
import TablePage from "@/components/TablePage.vue";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
import { OperatorEnum } from "@/types";
import { PageParams } from "@/types/Basics";
import SysSettingApi from "@/api/SysSettingApi";
import AppSettingApi, { AppCategoryItem } from "@/api/AppSettingApi";
import mittBus from "@/utils/mitt";
const router = useRouter();
const store = useDefaultStore();
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const isSuperAdmin = computed(() => store.userInfo.type == 1);
const isAdmin = computed(() => store.getIsAdmin);
const tabIndex = ref("basic");
const currentSchId = ref<number>(0);
const treeData = ref<any[]>([
{ id: 0, title: "平台默认设置", selected: true },
]);
// 当前选中的机构路径(面包屑),默认平台默认设置
const currentSchPath = ref<{ id: number; title: string }[]>([
{ id: 0, title: "平台默认设置" },
]);
function findPathInTree(list: any[], id: number): { id: number; title: string }[] | null {
for (const n of list || []) {
if (n.id === id) return [{ id: n.id, title: n.title }];
if (n.children && n.children.length) {
const sub = findPathInTree(n.children, id);
if (sub) return [{ id: n.id, title: n.title }, ...sub];
}
}
return null;
}
function onTreeSelect(nodes: any[]) {
if (!nodes || nodes.length == 0) return;
const node = nodes[0];
currentSchId.value = node.id;
if (node.id === 0) {
currentSchPath.value = [{ id: 0, title: "平台默认设置" }];
} else {
const path = findPathInTree(treeData.value, node.id);
currentSchPath.value = path && path.length ? path : [{ id: node.id, title: node.title }];
}
loadBasicData();
}
function mapTree(list: any[]): any[] {
return (list || []).map((item: any) => ({
id: item.id,
title: item.name,
children: item.children && item.children.length ? mapTree(item.children) : [],
}));
}
function getTreeData() {
SysSettingApi.getSchTree()
.then((resp: any) => {
treeData.value = [{ id: 0, title: "平台默认设置", selected: true }, ...mapTree(resp.children)];
})
.catch(() => {
treeData.value = [{ id: 0, title: "平台默认设置", selected: true }];
});
}
// ==================== 基础设置(Logo + 壁纸) ====================
const logoList = ref<any[]>([]);
const wallpaperList = ref<any[]>([]);
const logoUploadAction = computed(() => {
let url = $conf.serviceHost + "/api/logoUpload";
if (isSuperAdmin.value && currentSchId.value > 0) url += "?schId=" + currentSchId.value;
return url;
});
const wallpaperUploadAction = computed(() => {
let url = $conf.serviceHost + "/api/wallpaperUpload";
if (isSuperAdmin.value && currentSchId.value > 0) url += "?schId=" + currentSchId.value;
return url;
});
async function loadBasicData() {
const [walls, logos] = await Promise.all([
SysSettingApi.getSchWallpaperManageList(isSuperAdmin.value ? currentSchId.value : undefined),
SysSettingApi.getSchLogoManageList(isSuperAdmin.value ? currentSchId.value : undefined),
]);
wallpaperList.value = walls || [];
logoList.value = logos || [];
}
function wallpaperSuccess() {
loadBasicData();
}
function logoSuccess() {
loadBasicData();
}
async function onDeleteWallpaper(item: any) {
await SysSettingApi.deleteWallById(item.id);
Message.success("删除成功");
loadBasicData();
}
async function onDeleteLogo(item: any) {
await SysSettingApi.deleteLogoById(item.id);
Message.success("删除成功");
loadBasicData();
}
// ==================== 应用管理(AppCategory) ====================
const roleTypeList = [
{ k: 1, v: "校管理员" },
{ k: 2, v: "教师" },
{ k: 3, v: "学生" },
];
const roleTypeTab = ref("1");
const dataParmas = reactive({ name: "" });
const pageParams: PageParams = reactive({ page: 1, count: 10 });
const refTablePage = ref<any>(null);
const tableHeight = Utils.useTableHeight({ isSearch: true, isTabs: true, isPage: true });
const tableDataApi = (params: PageParams) =>
AppSettingApi.getAppCategoryList({
...params,
roleType: Number(roleTypeTab.value),
name: dataParmas.name || undefined,
});
const columns = ref<any[]>([
{ type: "index", title: "序号", width: 70, align: "center" },
{ title: "应用名称", width: 200, align: "left", key: "name" },
{
title: "数据类别",
width: 100,
align: "left",
render: (h: any, params: any) => {
let find = roleTypeList.find(x => x.k == params.row.roleType);
return h("span", !find ? "未知" : find.v);
},
},
{ title: "接口地址", align: "left", key: "apiUrl", tooltip: true },
{ title: "数据解析", align: "center", key: "dataKey" },
{
title: "操作",
width: 180,
align: "center",
render: (h: any, params: any) => {
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => showCategory(params.row.id)),
Utils.operatingTextHandle(h, "模板管理", "mr-2", () => {
let { id, name } = params.row;
router.push({ name: "SysTempManage", params: { id }, query: { name } });
}),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteCategory(params.row.id)),
];
},
},
]);
const isSubmitLoading = ref(false);
const currentId = ref<number | null>(null);
const parmasList = ref<any[]>([]);
const isModal = ref(false);
const formData = reactive<AppCategoryItem>({ id: null as any, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
const ruleValidate = {
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
};
watch(
parmasList,
val => {
formData.paramsKey = JSON.stringify(val);
},
{ deep: true }
);
function onRoleTypeChange() {
refTablePage.value?.reloadData();
}
function onSearch() {
refTablePage.value?.reloadData();
}
function showCategory(id?: number) {
if (typeof id === "undefined") {
currentId.value = null;
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
parmasList.value = [
{ k: "userId", v: "", type: 1 },
{ k: "schId", v: "", type: 2 },
];
} else {
const row = refTablePage.value.list.find((x: any) => x.id == id);
if (!row) return;
currentId.value = id;
Object.assign(formData, { id: row.id, name: row.name, apiUrl: row.apiUrl, dataKey: row.dataKey });
try {
parmasList.value = JSON.parse(row.paramsKey || "[]");
} catch (e) {
parmasList.value = [];
}
}
isModal.value = true;
}
async function onEditSave() {
if (!formData.name) {
Message.info("请填写应用名称");
return;
}
isSubmitLoading.value = true;
try {
const item: AppCategoryItem = { ...formData, roleType: Number(roleTypeTab.value) };
await AppSettingApi.saveAppCategory(item);
Message.success(`${currentId.value == null ? "添加" : "操作"}成功!`);
refTablePage.value.reloadData(currentId.value == null ? OperatorEnum.add : OperatorEnum.edit);
isModal.value = false;
isSubmitLoading.value = false;
currentId.value = null;
} catch (error) {
console.error(error);
isSubmitLoading.value = false;
}
}
function onReset() {
currentId.value = null;
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
parmasList.value = [];
isModal.value = false;
}
async function onDeleteCategory(id: number) {
try {
await AppSettingApi.deleteAppCategory(id);
Message.success("操作成功!");
refTablePage.value.reloadData(OperatorEnum.del);
} catch (error) {
console.error(error);
}
}
// ==================== 生命周期 ====================
async function onSwitchRole() {
if (!isAdmin.value) return;
if (isSuperAdmin.value) {
currentSchId.value = 0;
getTreeData();
}
await loadBasicData();
refTablePage.value?.reloadData();
}
onMounted(async () => {
if (!isAdmin.value) return;
if (isSuperAdmin.value) {
getTreeData();
}
await loadBasicData();
mittBus.on("switchRole", onSwitchRole);
});
onUnmounted(() => {
mittBus.off("switchRole", onSwitchRole);
});
</script>
<style lang="scss" scoped>
.syssetting-wrap {
height: 100%;
padding: 15px;
.left-box {
width: 300px;
height: 100%;
background: #fff;
padding: 15px;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
overflow-y: auto;
.tree-title {
margin-bottom: 10px;
color: #333;
}
}
.right-box {
height: 100%;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
padding: 15px;
overflow: hidden;
display: flex;
flex-direction: column;
&.ml-315 {
margin-left: 315px;
}
}
.sch-crumb {
margin: 0 0 12px;
padding: 8px 14px;
background: #f5f7fa;
border-radius: 6px;
color: #606266;
font-size: 13px;
.crumb-item {
&.is-last {
color: #303133;
font-weight: 600;
}
}
:deep(.ivu-breadcrumb-item-separator) {
margin: 0 8px;
color: #c0c4cc;
}
}
.syssetting-tabs {
flex: 1;
min-height: 0;
}
}
.no-permission {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
.basic-wrap {
height: calc(100% - 44px);
overflow-y: auto;
padding: 18px 24px 24px;
.setting-item {
margin-bottom: 28px;
& + .setting-item {
padding-top: 20px;
border-top: 1px dashed #ebeef5;
}
.item-title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 600;
color: #303133;
line-height: 22px;
.tip {
font-size: 12px;
color: #909399;
font-weight: 400;
margin-left: 4px;
}
}
}
.logo-uploader {
display: block;
}
.logo-ul {
display: flex;
flex-wrap: wrap;
gap: 14px;
li {
position: relative;
width: 120px;
height: 90px;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
img {
width: 100%;
height: 100%;
object-fit: contain;
background: #f7f8fa;
}
&.add-cell {
padding: 0;
border: none;
overflow: visible;
&:hover {
border: none;
box-shadow: none;
}
}
}
}
.wallpaper-ul {
display: flex;
flex-wrap: wrap;
gap: 16px;
li {
position: relative;
width: 250px;
height: 140px;
flex-shrink: 0;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.uploader {
padding: 0;
border: none;
overflow: visible;
&:hover {
border: none;
box-shadow: none;
}
}
}
// 强约束 view-ui-plus Upload 组件渲染层都撑满 li
li.uploader :deep(.ivu-upload),
li.uploader :deep(.ivu-upload-select) {
display: flex;
width: 100%;
height: 100%;
}
}
.badge {
position: absolute;
left: 8px;
top: 8px;
z-index: 2;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
color: #fff;
line-height: 18px;
&.sys {
background: #5979ea;
}
&.sch {
background: #45b972;
}
}
.operate {
position: absolute;
right: 8px;
top: 8px;
z-index: 2;
a.del {
background: rgba(0, 0, 0, 0.5);
color: #fff;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
line-height: 18px;
transition: background-color 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.7);
}
}
}
.add-box {
width: 100%;
height: 100%;
border: 1px dashed #dcdfe6;
border-radius: 6px;
background: #fafbfc;
text-align: center;
color: #909399;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
box-sizing: border-box;
transition: border-color 0.2s, color 0.2s, background-color 0.2s;
p {
font-size: 13px;
line-height: 1;
}
&:hover {
border-color: #5979ea;
color: #5979ea;
background: #f3f6fd;
}
&--logo {
width: 120px;
height: 90px;
}
&--wallpaper {
width: 250px;
height: 140px;
gap: 10px;
}
}
.logo-upload {
display: block;
width: 120px;
height: 90px;
}
}
.app-manage {
height: calc(100% - 44px);
overflow: hidden;
.search-wrap {
margin-bottom: 10px;
}
.parmas-ul {
.ivu-form-item-label {
float: left;
margin-right: 8px;
line-height: 32px;
}
}
}
</style>
-235
View File
@@ -1,235 +0,0 @@
<template>
<div class="tempmanage-wrap fix">
<div class="file-box fl">
<h4 class="file-title">{{ tempName }}</h4>
<ul class="file-list">
<li
v-for="item in fileList"
:key="item.name"
:class="{ active: currentFileName == item.name }"
@click="onSelectFile(item)"
>
<SvgIcon name="menu-2" size="14" color="#5979ea" style="margin-right: 6px" />
{{ item.name }}
</li>
<li v-if="fileList.length == 0" class="empty">暂无文件,请上传 zip/rar</li>
</ul>
</div>
<div class="main-box">
<div class="fix mb-12">
<Upload
:on-success="onUploadSuccess"
:show-upload-list="false"
:action="tempUploadAction"
:accept="fileAccept"
:data="uploadParams"
:with-credentials="true"
:headers="uploadHeaders"
class="fl"
>
<Button class="fl mr-12" type="primary" size="large">上传应用</Button>
</Upload>
<Button class="fl mr-12" type="primary" size="large" @click="downTemp">下载应用</Button>
<h4 class="title fl tc">{{ tempName }}模板空间</h4>
</div>
<div class="editor-box">
<Codemirror
v-model="currentContent"
:extensions="extensions"
:style="{ height: '100%' }"
:indent-with-tab="true"
placeholder="请选择左侧文件进行编辑"
/>
<div class="editor-footer fix">
<Button type="primary" :disabled="!currentFileName" :loading="isSaving" @click="saveTemp">
保存更改
</Button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { Codemirror } from "vue-codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { xml } from "@codemirror/lang-xml";
import { json } from "@codemirror/lang-json";
import { EditorView } from "@codemirror/view";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
import AppSettingApi from "@/api/AppSettingApi";
const route = useRoute();
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const appId = computed(() => (route.params.id as string) || "");
const tempName = computed(() => (route.query.name as string) || "");
const fileList = ref<any[]>([]);
const currentFileName = ref<string | null>(null);
const currentContent = ref<string>("");
const isSaving = ref(false);
const fileAccept = ".rar,.zip";
const tempUploadAction = computed(() => $conf.serviceHost + "/api/tempUpload");
const uploadParams = computed(() => ({ appModId: appId.value }));
const extensions = computed(() => {
if (!currentFileName.value) return [EditorView.lineWrapping];
const suffix = currentFileName.value.substring(currentFileName.value.lastIndexOf(".") + 1).toLowerCase();
let lang: any[] = [];
if (["js", "ts", "jsx", "tsx"].includes(suffix)) lang = [javascript()];
else if (suffix == "json") lang = [json()];
else if (["css", "less", "scss"].includes(suffix)) lang = [css()];
else if (suffix == "html" || suffix == "htm") lang = [html()];
else if (suffix == "xml") lang = [xml()];
return [...lang, EditorView.lineWrapping];
});
async function getFileList() {
const list = await AppSettingApi.getTempList(Number(appId.value));
fileList.value = list || [];
}
async function onSelectFile(item: any) {
currentFileName.value = item.name;
await loadContent(item.path);
}
/** 读取文件内容:静态资源跨域读取,携带 yschapirequest 头触发后端 CORS 过滤器 */
async function loadContent(path: string) {
try {
const resp = await fetch(path, {
headers: { yschapirequest: "1" },
credentials: "include",
});
if (!resp.ok) {
Message.error("读取文件失败");
return;
}
currentContent.value = await resp.text();
} catch (e) {
console.error(e);
Message.error("读取文件失败");
}
}
function onUploadSuccess() {
Message.success("上传成功!");
AppSettingApi.updateResDate(Number(appId.value));
getFileList();
}
function downTemp() {
window.location.href =
$conf.serviceHost + `/api/downloadTemp?appModId=${appId.value}&name=${encodeURIComponent(tempName.value)}`;
}
async function saveTemp() {
if (!currentFileName.value) return;
isSaving.value = true;
try {
await AppSettingApi.saveTempFile(Number(appId.value), currentFileName.value, currentContent.value);
Message.success("保存成功!");
} catch (e) {
console.error(e);
} finally {
isSaving.value = false;
}
}
onMounted(() => {
getFileList();
});
</script>
<style lang="scss" scoped>
.tempmanage-wrap {
height: 100%;
padding: 15px;
.file-box {
width: 260px;
height: 100%;
background: #fff;
border-radius: 5px;
padding: 15px;
overflow-y: auto;
box-sizing: border-box;
.file-title {
margin-bottom: 10px;
color: #333;
font-size: 14px;
}
.file-list {
li {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: #f5f7fa;
}
&.active {
background: #eaf1ff;
color: #5979ea;
}
&.empty {
color: #999;
cursor: default;
}
}
}
}
.main-box {
margin-left: 275px;
height: 100%;
background: #fff;
border-radius: 5px;
padding: 15px;
box-sizing: border-box;
.title {
margin-left: 20px;
line-height: 40px;
}
.editor-box {
height: calc(100% - 52px);
border: 1px solid #e8eaec;
border-radius: 5px;
overflow: hidden;
:deep(.cm-editor) {
height: calc(100% - 48px);
}
.editor-footer {
height: 48px;
padding: 8px 12px;
border-top: 1px solid #e8eaec;
background: #fafafa;
}
}
}
}
</style>
+17 -41
View File
@@ -3,11 +3,12 @@
只在本路由的 document 内加载通道(组件挂载时动态 append init 脚本),
档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。
纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。
DOM 结构对齐 Notice layout(header-container / left-menu / path-nav / main-container),
观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。
默认头像轮(2026-09-30):logo 换云海图形、用户胶囊补三态头像圆 + icon-logout、
右侧补静态配色胶囊、左菜单换 iconfont + 可展开子菜单(预览 markup 落后于通道皮肤修复)。 -->
<div class="theme-preview-root">
DOM 结构逐层对齐 Notice(layout.vue + App.vue):.layout-wrap 作用域内放
header-container / left-menu / path-nav,内容区用 .base-wrap-main > .main-wrapper > .main-container;
通道皮肤(skin.src.css)的侧栏/顶栏/面包屑规则全部以 .layout-wrap 为祖先,故根必须挂该类,
侧栏深色/浅色/跟随顶栏、渐变Header 这些档位令牌(html.side-deep/.grad-on…)才会被消费到。
观感(配色、侧栏形态、内容区卡片)由通道按 URL 覆盖参数(?yh-theme= 等)驱动的档位提供,本页不写死。 -->
<div class="theme-preview-root layout-wrap">
<header class="header-container fix">
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
<div class="delimiter fl align-middle"></div>
@@ -20,8 +21,7 @@
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i class="iconfont icon-logout"></i></a>
</div>
<!-- 配色胶囊:静态展示(不接 ThemeSwitcher,避免预览里出现可点控件与真实切换器语义混淆)。
置于 header-aside 之后:本页不吃 .layout-wrap 皮肤(flex order 失效、走 float),
float:right 的 DOM 靠后者居左——才能复刻 Notice「配色在左、用户胶囊在右」 -->
顶栏是 flex,皮肤给 .theme-cap{order:1} / .header-aside{order:2} → 胶囊恒落在用户胶囊左侧。 -->
<div class="theme-cap fr"><span class="cap-btn">配色 ▾</span></div>
</header>
<div class="left-menu">
@@ -44,13 +44,15 @@
<div class="path-nav">
<span>首页</span><span class="crumb-sep">/</span><span>通知管理</span><span class="crumb-sep">/</span><span>通知列表</span>
</div>
<div class="preview-main">
<div class="base-wrap-main">
<div class="main-wrapper">
<div class="main-container">
<h3 class="panel-title">通知列表</h3>
<Table :columns="columns" :data="rows" size="small" :border="true" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
@@ -91,25 +93,17 @@ onMounted(() => {
</script>
<style scoped lang="scss">
/* 本页只补最小布局;配色/圆角/阴影等观感全部由通道 base.layout.css 的档位类驱动 */
/* 本页只补"铺满 iframe + 纯展示"这层壳;配色、顶栏/侧栏/面包屑观感、内容区卡片全部交给通道
(骨架 skeleton.src.css 出结构、皮肤 skin.src.css 出观感,皮肤规则以 .layout-wrap 为祖先——
故根元素同时挂 .layout-wrap,档位令牌变化才会实时反映到预览上)。 */
.theme-preview-root {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none; /* 纯展示:整页不可交互 */
background: #f5f6f8;
font-size: 14px;
}
.header-container {
h4 {
color: #fff;
font-size: 16px;
font-weight: 600;
margin: 0 12px;
}
}
/* 配色胶囊(静态展示):观感对齐 ThemeSwitcher .theme-btn(通道皮肤只给 .theme-cap 排版,胶囊样式在切换器组件内) */
.cap-btn {
display: flex;
@@ -124,35 +118,17 @@ onMounted(() => {
font-size: 12.5px;
}
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距(Notice 同款覆盖) */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 子菜单缩进:预览页不吃 .layout-wrap 前缀的皮肤层,骨架 a 无缩进差,补出层级(对齐皮肤 24px 缩进) */
.left-menu .child-item-wrap a {
padding-left: 59px;
}
/* 面包屑分隔符:预览用纯 span 复刻(Notice 侧是 iView Breadcrumb,皮肤只作用于 .ivu-breadcrumb-*) */
.path-nav .crumb-sep {
margin: 0 8px;
color: #c5c8cd;
}
.preview-main {
position: fixed;
top: calc(var(--topbar-h, 52px) + var(--crumb-h, 44px));
left: var(--side-w, 200px);
right: 0;
bottom: 0;
padding: 14px 24px 24px;
overflow: auto;
.panel-title {
/* 内容卡标题:通道未定义,本页补最小排版(色值取通道文本色变量,未加载时回退) */
.panel-title {
font-size: 15px;
font-weight: 600;
color: #2c3138;
color: var(--text, #2c3138);
margin: 4px 0 12px;
}
}
</style>
+105 -6
View File
@@ -66,9 +66,10 @@
:visible="dragObj.selVisible"
@on-click="onWidgetSelItemClick"
trigger="custom"
transfer-class-name="wg-dd"
>
<div class="operate-btn" @click="showWidgetSelClick">
<SvgIcon class="fl" name="widget-add" size="24" color="#cbd5df" hoverColor="#fff" />
<SvgIcon class="fl" name="widget-add" size="24" color="currentColor" />
</div>
<template #list>
<DropdownMenu style="min-width: 120px; max-height: 200px; overflow-y: auto">
@@ -79,7 +80,7 @@
v-if="!!item.checked"
class="fr mt-3"
name="widget-checked"
color="#2d8cf0"
color="currentColor"
size="14"
></SvgIcon>
</div>
@@ -90,7 +91,7 @@
<div class="widget-header-drag" @mousedown="(e:any)=>dragObj.isDrag = e.buttons === 1"></div>
<div class="operate-btn" @click="onClickWidgetHide">
<SvgIcon class="fr" name="widget-sub" size="24" color="#cbd5df" hoverColor="#fff" />
<SvgIcon class="fr" name="widget-sub" size="24" color="currentColor" />
</div>
</div>
@@ -99,6 +100,9 @@
<div class="widget-item pr" v-for="item in widgetAppList">
<div class="widget-app-header">
<span>{{ item.name }}</span>
<!-- 内置组件在 header 里的挂载点:组件用 <Teleport> 把自身控件放进来(如课表班级切换器) -->
<div class="widget-header-right">
<span class="widget-header-extra" :id="'wg-hdr-' + item.id"></span>
<SvgIcon
@click="onWidgetSelItemClick(item.id)"
class="fr"
@@ -108,6 +112,16 @@
hoverColor="#646566"
/>
</div>
</div>
<!-- ① front 工程内置组件:内容在代码库,不走 iframe -->
<component
v-if="item.compType === 1 && widgetRegistry[item.code]"
:is="widgetRegistry[item.code]"
:widget-id="item.id"
:overview-url="item.overviewUrl"
@loaded="onWidgetLoaded(item.id, $event)"
/>
<template v-else>
<Skeleton
style="padding: 15px"
:loading="item.loading"
@@ -115,20 +129,27 @@
:paragraph="{ rows: 4 }"
animated
/>
<!-- ② 自定义 HTML 片段:内容来自 widget_catalog.content,srcdoc 内联,无外部地址 -->
<iframe
v-if="item.compType === 2 && !!item.content"
v-show="!item.loading"
:style="{ height: item.height + 'px' }"
:id="'iframe_widget_' + item.id"
:data-id="item.id"
@load="onIframeLoad(item.id)"
:src="item.url"
:srcdoc="widgetFragmentSrc(item)"
sandbox="allow-scripts allow-same-origin allow-forms"
class="widget-app-iframe"
></iframe>
<!-- ③ 兜底:注册表未命中 / 片段内容为空,渲染空态而非白屏(拉取途中交给骨架) -->
<div v-else-if="!isFragmentPending(item)" class="no-data">该小工具暂不可用</div>
</template>
</div>
</vuescroll>
<template v-if="widgetAppList.length == 0">
<div class="widget-empty-box">
<SvgIcon name="widget-empty" color="#2d8cf0" size="120"></SvgIcon>
<!-- 用 -vec(纯矢量,颜色跟随 currentColor);widget-empty 是内嵌 PNG 的假 SVG,颜色烤死 -->
<SvgIcon name="widget-empty-vec" color="currentColor" size="120"></SvgIcon>
<h4>单击 + 按钮可添加小工具。</h4>
</div>
</template>
@@ -147,8 +168,10 @@ import BaseApi from "@/api/BaseApi";
import VueDragResize from "vue-drag-resize/src";
import { useDefaultStore } from "@/store";
import SvgIcon from "@/components/SvgIcon.vue";
import { widgetRegistry } from "@/components/widgets";
import { cloneDeep, debounce, difference, includes, once, pick, remove } from "lodash";
import UserSettingApi from "@/api/UserSettingApi";
import { wrapFragment } from "@/utils/widgetFragment";
import { bschLinkPage } from "@/utils/bschLinkPage";
import {
BschLoadTypeEnum,
@@ -193,9 +216,20 @@ const dragObj = reactive({
parentH: 0,
});
/**
* vuescroll 面板参数。
* ⚠️ scrollingX 必须显式 false:vuescroll 默认 true,此时 native-panel 会给 .__view 内联
* `width: fit-content; min-width: 100%`,`.widget-item` 的包含块就被「最长的 nowrap 文本」
* 撑宽(白卡 357px → 内容 ≈425px)⇒ 列表条目 text-overflow 永不触发(文字顶到卡边被硬裁)、
* 七日条按更宽容器等分(六/日被裁),面板 scrollWidth > clientWidth ⇒ 外框出横向滚动条。
* 关掉后 .__view 走 `width: 100%` 分支。面板内组件均不依赖横向滚动:
* 课表自带 .tt-scroll(纵向)、自定义片段是 iframe width:100%。
*/
const widgetOps = {
vuescroll: {},
scrollPanel: {},
scrollPanel: {
scrollingX: false,
},
rail: {},
bar: {
background: "#333",
@@ -269,6 +303,13 @@ watch(
widgetAppList.value = val.map(x => {
return { ...x, loading: true, height: 20 };
});
widgetAppList.value.forEach(item => {
// 注册表未命中的内置项会落到空态分支,留日志便于发现两处(widgetRegistry / widget_catalog)不同步
if (item.compType === 1 && !widgetRegistry[item.code]) {
console.warn("[widget] 内置类型未命中注册表:", item.code);
}
if (item.compType === 2) loadFragmentContent(item);
});
},
{
immediate: true,
@@ -396,6 +437,43 @@ function iframeCallback(ev: MessageEvent<any>) {
});
//console.log(find, id, height);
}
/** 内置组件自报高度:与 iframeCallback 同一收口(find.height),不再走 postMessage */
function onWidgetLoaded(id: number, height: number) {
const find = widgetAppList.value.find(x => x.id == id);
if (!find) return;
find.height = height;
requestAnimationFrame(() => {
find.loading = false;
});
}
/** 片段正文按需拉一次:不进 getUserSettings,避免每次进桌面下发全部 HTML */
async function loadFragmentContent(item: any) {
try {
const data: any = await UserSettingApi.getWidgetContent(item.code);
item.content = (data && data.content) || "";
// 取数键随内容一起回:候选池载荷里没有 data_code,桥要用它才知道打哪个数据键
item.dataCode = (data && data.dataCode) || item.code;
} catch (e) {
console.warn("[widget] 片段内容拉取失败:", item.code, e);
item.content = "";
item.dataCode = item.code;
}
item.contentLoaded = true;
if (!item.content) {
const find = widgetAppList.value.find(x => x.id == item.id);
if (find) find.loading = false;
}
}
function isFragmentPending(item: any) {
return item.compType === 2 && !item.contentLoaded;
}
function widgetFragmentSrc(item: any) {
return wrapFragment(item.content || "", item.id, item.dataCode || item.code);
}
async function getAppList() {
appList.value = await UserSettingApi.getUserDesktopAppList();
let arr = cloneDeep(appList.value);
@@ -452,4 +530,25 @@ async function openLinkPage(item: any) {
.vdr.active::before {
content: none;
}
/* 「+」添加小组件下拉:与卡片同族(玻璃层)。下拉就地渲染在 .widget-wrap 内,可直接复用其玻璃令牌
(--wg-glass-*)保持同族;底色在同色相上加深一档,保证浮层层次与白字可读性。 */
.ivu-select-dropdown.wg-dd {
padding: 4px 0;
background-color: rgba(15, 23, 42, 0.72);
-webkit-backdrop-filter: blur(18px) saturate(140%);
backdrop-filter: blur(18px) saturate(140%);
border: 1px solid var(--wg-glass-border, rgba(255, 255, 255, 0.18));
border-radius: 6px;
box-shadow: var(--wg-glass-shadow, 0 10px 36px rgba(0, 0, 0, 0.3));
}
.ivu-select-dropdown.wg-dd .ivu-dropdown-item {
color: var(--wg-fg, rgba(255, 255, 255, 0.94));
text-shadow: var(--wg-fg-shadow, 0 1px 2px rgba(0, 0, 0, 0.35));
}
.ivu-select-dropdown.wg-dd .ivu-dropdown-item:hover {
background: rgba(255, 255, 255, 0.12);
}
</style>
+8 -2
View File
@@ -110,7 +110,7 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { storeToRefs } from "pinia";
import BaseApi from "@/api/BaseApi";
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
@@ -392,6 +392,12 @@ onMounted(() => {
loadApps();
mittBus.on("msgCenter:newMessage", onWsNewMessage);
});
// 切换身份后随新身份学校口径重拉(curRoleUid 由 App.vue switchRole 统一 bump,与桌面/应用中心同机制)
watch(() => store.curRoleUid, () => {
loadList(true);
loadSummary();
loadApps();
});
onUnmounted(() => {
mittBus.off("msgCenter:newMessage", onWsNewMessage);
clearTimeout(searchTimer);
@@ -423,7 +429,7 @@ function fmtTime(sendTime?: string) {
<style scoped lang="scss">
/* 壳:透明,不平铺白,壁纸透出(B 方案)。
padding 与 .set-shell / .syssetting-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
padding 与 .set-shell 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
.msg-shell {
display: flex;
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
+403
View File
@@ -0,0 +1,403 @@
<template>
<div
class="wcard"
:class="{ off: !item.enabled, dragging, 'drag-over': dragOver }"
:draggable="draggable"
@mouseup="draggable = false"
@dragstart="onDragStart"
@dragover.prevent="onDragOver"
@drop.prevent="onDrop"
@dragend="onDragEnd"
>
<div class="wc-top">
<span class="badge" :class="item.compType === 1 ? 'builtin' : 'custom'">{{ typeLabel }}</span>
<template v-if="item.compType === 1">
<span v-if="registryHit" class="badge ok" title="前端 widgetRegistry 已收录该键">注册表 ✓</span>
<span v-else class="badge warn" title="前端注册表未收录,用户侧会落「暂不可用」空态">⚠ 未收录</span>
</template>
<span v-else class="badge mute" title="片段正文存在 widget_catalog.content">内容存库</span>
<span class="drag" title="按住拖拽排序" @mousedown.stop="draggable = true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor">
<circle cx="9" cy="5.5" r="1.6" />
<circle cx="15" cy="5.5" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18.5" r="1.6" />
<circle cx="15" cy="18.5" r="1.6" />
</svg>
</span>
</div>
<div class="wc-name">
<span>{{ item.name }}</span>
<span v-if="item.alias" class="badge alias" :title="`校级别名;超管下发名称:${item.originalName || item.name}`">别名</span>
<span v-if="adaptive" class="tag-adaptive">按角色适配</span>
</div>
<div class="wc-code">{{ item.code }}</div>
<div class="wc-meta">
<div class="row">
<span class="k">取数键</span>
<span class="v">
<code v-if="item.dataCode">{{ item.dataCode }}</code>
<span v-else class="dim">—</span>
</span>
</div>
<div class="row">
<span class="k">可见</span>
<span class="v">
<span class="rchip" :class="scopeCls">{{ scopeText }}</span>
<span v-if="roleAll" class="rchip all">全角色</span>
<template v-else>
<span v-for="r in roleList" :key="r" class="rchip">{{ roleNameOf(r) }}</span>
</template>
</span>
</div>
<div class="row">
<span class="k">更新</span>
<span class="v dim">{{ item.updateTime || "—" }}</span>
</div>
</div>
<div class="wc-ft">
<span class="wc-ops">
<a class="op" @click="$emit('edit', item)">{{ isSuperView ? "编辑" : "设置" }}</a>
<a class="op" @click="$emit('preview', item)">预览</a>
<a v-if="isSuperView" class="op danger" @click="$emit('del', item)">删除</a>
</span>
<span class="wc-sw">
<span class="sw" :class="{ 'is-on': !!item.enabled }" @click="$emit('toggle', item)"></span>
<span class="wc-sw-t" :class="{ on: !!item.enabled }">{{ item.enabled ? "已启用" : "已下线" }}</span>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import type { WidgetCatalog } from "@/types";
import { RoleTypeEnum } from "@/types";
const props = defineProps<{
item: WidgetCatalog;
isSuperView: boolean;
registryHit: boolean;
schools: { id: number; name: string }[];
dragging: boolean;
dragOver: boolean;
}>();
const emit = defineEmits<{
(e: "edit" | "preview" | "toggle" | "del", item: WidgetCatalog): void;
(e: "dragstart" | "dragover" | "drop", id: number | undefined): void;
(e: "dragend"): void;
}>();
const draggable = ref(false);
const typeLabel = computed(() => (props.item.compType === 1 ? "内置组件" : "自定义片段"));
const adaptive = computed(() => props.item.compType === 1 && props.item.code === "syllabus");
const roleKeys = computed(() => {
const raw = props.item.roleTypes;
if (!raw) return [] as number[];
return raw
.split(",")
.map(x => Number(x.trim()))
.filter(x => !isNaN(x))
.sort();
});
const roleAll = computed(() => roleKeys.value.length === 0 || roleKeys.value.length === 3);
const roleList = computed(() => (roleAll.value ? [] : roleKeys.value));
const roleNameOf = (k: number) => ({ [RoleTypeEnum.Teacher]: "教师", [RoleTypeEnum.SchAdmin]: "校领导", [RoleTypeEnum.Student]: "学生" })[k] || `角色 ${k}`;
const schoolNameOf = (id: number) => props.schools.find(x => x.id === id)?.name || `学校 ${id}`;
const scopeCls = computed(() => (props.item.scopeType === 1 ? "sch" : props.item.scopeType === 2 ? "no" : "all"));
const scopeText = computed(() => {
const names = (props.item.scopeSchIds || []).map(schoolNameOf).join("、");
if (props.item.scopeType === 1) return `指定可见 ${names}`;
if (props.item.scopeType === 2) return `指定不可见 ${names}`;
return "全部学校";
});
function onDragStart(e: DragEvent) {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(props.item.id));
}
emit("dragstart", props.item.id);
}
function onDragOver() {
if (draggable.value) emit("dragover", props.item.id);
}
function onDrop() {
emit("drop", props.item.id);
draggable.value = false;
}
function onDragEnd() {
draggable.value = false;
emit("dragend");
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary; // 品牌主色唯一来源 = 项目 SCSS 变量
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
$wm-brand-line: color.mix(#ffffff, $wm-brand, 78%);
.wcard {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-line2: #e5e8ee;
--wm-ok: #12a150;
--wm-ok-bg: #e8f7ee;
--wm-warn: #b54708;
--wm-warn-bg: #fff4e5;
--wm-danger: #d92d20;
--wm-danger-bg: #fdecea;
border: 1px solid var(--wm-line2);
border-radius: 10px;
padding: 13px 14px 0;
background: #fff;
transition: border-color 0.16s, box-shadow 0.16s, transform 0.16s;
position: relative;
// 同一行卡片等高、底部操作行对齐(网格 stretch + 底部自适应)
display: flex;
flex-direction: column;
&:hover {
border-color: $wm-brand-line;
box-shadow: 0 6px 18px rgba($wm-brand, 0.12);
transform: translateY(-1px);
}
&.off {
background: #fcfcfd;
.wc-name {
color: var(--wm-t3);
}
}
&.dragging {
opacity: 0.4;
}
&.drag-over {
border-color: $wm-brand;
box-shadow: 0 0 0 2px rgba($wm-brand, 0.22);
}
}
.badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: $wm-brand-weak;
color: $wm-brand;
}
&.custom {
background: var(--wm-warn-bg);
color: var(--wm-warn);
}
&.ok {
background: var(--wm-ok-bg);
color: var(--wm-ok);
}
&.warn {
background: var(--wm-danger-bg);
color: var(--wm-danger);
}
&.mute {
background: #f0f1f3;
color: var(--wm-t3);
}
&.alias {
background: #eaf6ff;
color: #1677b3;
}
}
.wc-top {
display: flex;
align-items: center;
gap: 5px;
min-height: 22px;
}
.drag {
margin-left: auto;
width: 22px;
height: 22px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
color: #c4c9d2;
transition: all 0.15s;
flex-shrink: 0;
&:hover {
color: $wm-brand;
background: $wm-brand-weak;
}
&:active {
cursor: grabbing;
}
}
.wc-name {
font-size: 14px;
font-weight: 600;
color: var(--wm-t1);
margin-top: 9px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tag-adaptive {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
background: #eaf6ff;
color: #1677b3;
white-space: nowrap;
}
.wc-code {
font-size: 11.5px;
color: var(--wm-t4);
font-family: Consolas, Menlo, monospace;
margin-top: 2px;
}
.wc-meta {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--wm-line2);
display: flex;
flex-direction: column;
gap: 7px;
.row {
display: flex;
gap: 8px;
font-size: 12px;
line-height: 1.7;
.k {
width: 46px;
flex-shrink: 0;
color: var(--wm-t4);
}
.v {
color: var(--wm-t2);
min-width: 0;
}
.dim {
color: var(--wm-t4);
}
}
}
.rchip {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 7px;
border-radius: 5px;
font-size: 11px;
background: #eef1f6;
color: #5b6472;
margin-right: 4px;
margin-bottom: 2px;
white-space: nowrap;
&.all {
background: #f0f1f3;
color: var(--wm-t3);
}
&.sch {
background: #eaf6ff;
color: #1677b3;
}
&.no {
background: var(--wm-danger-bg);
color: var(--wm-danger);
}
}
.wc-ft {
display: flex;
align-items: center;
gap: 10px;
margin-top: auto;
padding: 9px 0 11px;
border-top: 1px solid var(--wm-line);
}
.wc-ops {
display: flex;
gap: 11px;
min-width: 0;
}
.op {
font-size: 12.5px;
color: $wm-brand;
cursor: pointer;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
&.danger {
color: var(--wm-danger);
}
}
.wc-sw {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
.sw {
position: relative;
display: inline-block;
width: 38px;
height: 21px;
border-radius: 11px;
background: #d5d8de;
cursor: pointer;
transition: background 0.18s;
vertical-align: middle;
flex-shrink: 0;
&::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 17px;
height: 17px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left 0.18s;
}
&.is-on {
background: $wm-brand;
&::after {
left: 19px;
}
}
}
.wc-sw-t {
font-size: 12px;
color: var(--wm-t4);
&.on {
color: var(--wm-ok);
}
}
</style>
File diff suppressed because it is too large Load Diff
+688
View File
@@ -0,0 +1,688 @@
<template>
<div class="widget-manage">
<!-- 视角切换:超管可切到校管理员视角预览可写范围;纯校管锁定在校管视角 -->
<div class="wm-viewbar">
<div v-if="isSuperUser" class="seg-tabs">
<div class="sg" :class="{ active: view === 'super' }" @click="setView('super')">超管视角</div>
<div class="sg" :class="{ active: view === 'school' }" @click="setView('school')">校管理员视角</div>
</div>
<span v-else class="role-chip">学校管理员</span>
<span class="grow"></span>
<span class="vb-tip">{{ isSuperView ? "超管视角 · 全部字段可改" : "校管视角 · 只能改本校的 启用 / 可见角色 / 排序 / 别名" }}</span>
</div>
<div class="note-bar key">
<span>🎯</span>
<span><b>这张卡能不能在桌面「+」里被勾选 = 三个条件同时成立:</b>① 启用 = 开 <b>AND</b> ② 可见范围命中当前学校
<b>AND</b> ③ 可见角色命中当前登录人角色。缺一即不进候选池。<b>名称、内容、取数只影响「勾选之后长什么样」,不影响「能不能勾」。</b></span>
</div>
<div class="note-bar warn">
<span>🔑</span>
<span v-if="isSuperView"><b>当前视角:超管(type==1)</b> —— 可新增 / 编辑内容 / 删除、可登记内置组件、可配「可见范围(全部 / 指定可见 / 指定不可见)」。切到「校管理员视角」看校级权限差异。</span>
<span v-else><b>当前视角:校管理员</b> —— 只能改本校({{ mySchName || "当前学校" }})已下发组件的
<b>启用 / 可见角色 / 排序 / 别名</b>;<b>没有</b>新增、改内容、删除、「指定学校范围」的入口。⚠️ 后端强制按登录校落库,不接受前端传入的 schId。</span>
</div>
<div class="grid-toolbar">
<div class="seg-tabs">
<div class="sg" :class="{ active: typeFilter === 'all' }" @click="typeFilter = 'all'">全部 <b>{{ items.length }}</b></div>
<div class="sg" :class="{ active: typeFilter === '1' }" @click="typeFilter = '1'">内置组件 <b>{{ countBuiltin }}</b></div>
<div class="sg" :class="{ active: typeFilter === '2' }" @click="typeFilter = '2'">自定义片段 <b>{{ countCustom }}</b></div>
</div>
<div class="tb-right">
<label class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-3.6-3.6" />
</svg>
<input v-model="keyword" placeholder="搜索名称 / 组件键 / 取数键" />
</label>
<button v-if="isSuperView" class="btn sm" @click="regVisible = true">+ 登记内置组件</button>
<button v-if="isSuperView" class="btn primary sm" @click="openNew">+ 新建自定义片段</button>
</div>
</div>
<div v-if="loading" class="wm-loading">加载中…</div>
<div v-else class="wgrid">
<div v-if="items.length === 0" class="empty-state">
<div class="es-ic">
<SvgIcon name="widget-empty-line" size="72" color="#c4c9d2" />
</div>
<h5>候选池是空的</h5>
<p>新环境不会自动种数据(手工种子只在 <code>resources/sql/widget_catalog.sql</code>,启动期 seeding 仅在库里一行都没有时才补 3 个内置键)。<br />可先把前端注册表里已有的内置组件登记进来。</p>
<div class="es-actions">
<button v-if="isSuperView" class="btn primary" :disabled="registering" @click="quickRegisterBuiltins">
一键登记 {{ registryCodes.length }} 个内置组件
</button>
<span v-else class="es-dim">请联系平台超管登记内置组件</span>
</div>
</div>
<template v-else>
<WidgetCard
v-for="item in visibleList"
:key="item.id"
:item="item"
:is-super-view="isSuperView"
:registry-hit="!!widgetRegistry[item.code]"
:schools="cardSchools"
:dragging="dragFrom === item.id"
:drag-over="dragTo === item.id"
@edit="openEdit"
@preview="openPreview"
@toggle="onToggle"
@del="onDelete"
@dragstart="onDragStart"
@dragover="onDragOver"
@drop="onDrop"
@dragend="onDragEnd"
/>
<div v-if="visibleList.length === 0" class="empty-state slim">
<p>没有匹配的组件,换个关键字或切回「全部」。</p>
</div>
<div v-if="isSuperView" class="wcard add" @click="openNew">
<span class="plus">+</span>
<span>新建自定义片段</span>
</div>
</template>
</div>
<div class="note-bar">
<span>ℹ️</span>
<span><b>可见范围三态</b>(超管专属):<code>全部学校</code> / <code>指定学校可见</code>(白名单,只有选中的学校下发)/
<code>指定学校不可见</code>(黑名单,除选中学校外都下发)。<br />校级可覆盖项(校管理员):<b>启用 / 可见角色 / 排序 / 别名</b>。</span>
</div>
<WidgetEditModal
:visible="editVisible"
:item="editItem"
:is-new="editIsNew"
:is-super-view="isSuperView"
:initial-tab="editTab"
:schools="schools"
:my-sch-id="mySchId"
@close="editVisible = false"
@saved="onSaved"
/>
<WidgetRegisterModal
:visible="regVisible"
:registry-codes="registryCodes"
:registered-codes="registeredCodes"
:next-sort="nextSort"
@close="regVisible = false"
@saved="onSaved"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import UserSettingApi from "@/api/UserSettingApi";
import { widgetRegistry } from "@/components/widgets";
import MyConfirm from "@/components/MyConfirm";
import SvgIcon from "@/components/SvgIcon.vue";
import { useDefaultStore } from "@/store";
import type { WidgetCatalog } from "@/types";
import WidgetCard from "./WidgetCard.vue";
import WidgetEditModal from "./WidgetEditModal.vue";
import WidgetRegisterModal from "./WidgetRegisterModal.vue";
const store = useDefaultStore();
const loading = ref(true);
const registering = ref(false);
const items = ref<WidgetCatalog[]>([]);
const schools = ref<{ id: number; name: string }[]>([]);
const typeFilter = ref<"all" | "1" | "2">("all");
const keyword = ref("");
/** 超管(userInfo.type==1)与校管(roleId∈{2,5,7})都可进本页,但可写范围不同 */
const isSuperUser = computed(() => store.userInfo?.type == 1);
const view = ref<"super" | "school">(isSuperUser.value ? "super" : "school");
const isSuperView = computed(() => isSuperUser.value && view.value === "super");
const mySchId = computed<number | null>(() => {
const id = store.getCurSchId;
return id && id > 0 ? id : null;
});
const mySchName = computed(() => store.curRoleInfo?.schName || "");
const editVisible = ref(false);
const editIsNew = ref(false);
const editItem = ref<WidgetCatalog | null>(null);
const editTab = ref<"cfg" | "cont" | "prev">("cfg");
const regVisible = ref(false);
const registryCodes = Object.keys(widgetRegistry);
const registeredCodes = computed(() => items.value.filter(x => x.compType === 1).map(x => x.code));
const nextSort = computed(() => items.value.reduce((max, x) => Math.max(max, Number(x.sort) || 0), 0) + 10);
const countBuiltin = computed(() => items.value.filter(x => x.compType === 1).length);
const countCustom = computed(() => items.value.filter(x => x.compType === 2).length);
/** 卡片里可见范围 chip 的学校名:超管取机构树,校管只有本校 */
const cardSchools = computed(() => {
if (schools.value.length > 0) return schools.value;
return mySchId.value ? [{ id: mySchId.value, name: mySchName.value || "本校" }] : [];
});
const visibleList = computed(() => {
const kw = keyword.value.trim().toLowerCase();
return items.value.filter(x => {
if (typeFilter.value !== "all" && String(x.compType) !== typeFilter.value) return false;
if (!kw) return true;
return [x.name, x.alias, x.code, x.dataCode].some(v => String(v || "").toLowerCase().includes(kw));
});
});
const dragFrom = ref<number | null>(null);
const dragTo = ref<number | null>(null);
onMounted(async () => {
await Promise.all([loadList(), loadSchools()]);
});
async function loadList() {
loading.value = true;
try {
items.value = (await UserSettingApi.getWidgetCatalogList()) || [];
} catch (e) {
console.warn("[widget] 候选池列表拉取失败", e);
items.value = [];
}
loading.value = false;
}
/** 机构树仅超管可取(后端 getSchTree 校验 type==1);校管视角由本校兜底 */
async function loadSchools() {
if (!isSuperUser.value) return;
try {
const tree: any = await UserSettingApi.getSchTree();
schools.value = flattenSchools(tree);
} catch (e) {
console.warn("[widget] 机构树拉取失败", e);
schools.value = [];
}
}
function flattenSchools(node: any): { id: number; name: string }[] {
const typed: { id: number; name: string }[] = [];
const leaves: { id: number; name: string }[] = [];
const walk = (n: any) => {
if (!n || typeof n !== "object") return;
const kids = Array.isArray(n.children) ? n.children : [];
if (kids.length > 0) {
kids.forEach(walk);
return;
}
const item = { id: Number(n.id), name: n.name || `学校 ${n.id}` };
if (Number(n.type) === 2) typed.push(item);
leaves.push(item);
};
if (Array.isArray(node)) node.forEach(walk);
else walk(node);
return typed.length > 0 ? typed : leaves;
}
function setView(v: "super" | "school") {
if (v === "school" && !isSuperUser.value) return;
if (v === view.value) return;
view.value = v;
Message.info(v === "super" ? "已切换:超管视角" : "已切换:校管理员视角");
}
function openNew() {
if (!isSuperView.value) return;
editItem.value = null;
editIsNew.value = true;
editTab.value = "cfg";
editVisible.value = true;
}
function openEdit(item: WidgetCatalog) {
editItem.value = item;
editIsNew.value = false;
editTab.value = "cfg";
editVisible.value = true;
}
function openPreview(item: WidgetCatalog) {
editItem.value = item;
editIsNew.value = false;
editTab.value = "prev";
editVisible.value = true;
}
async function onSaved() {
editVisible.value = false;
regVisible.value = false;
await loadList();
}
async function onToggle(item: WidgetCatalog) {
const next = item.enabled ? 0 : 1;
try {
await saveOne(item, { enabled: next });
item.enabled = next;
Message.success(next ? "已启用" : "已下线");
} catch (e) {
console.warn("[widget] 启停保存失败", e);
Message.error(typeof e === "string" ? e : "保存失败");
}
}
async function onDelete(item: WidgetCatalog) {
await MyConfirm({
title: "删除组件",
msg: `确定删除「${item.name}」(${item.code})吗?`,
info: "会一并删除它的可见范围点名与各校覆盖设置,删除后无法恢复。",
async onOk() {
await UserSettingApi.deleteWidgetCatalog(item.id as number);
},
});
await loadList();
}
function onDragStart(id?: number) {
dragFrom.value = id ?? null;
}
function onDragOver(id?: number) {
if (id !== dragFrom.value) dragTo.value = id ?? null;
}
function onDragEnd() {
dragFrom.value = null;
dragTo.value = null;
}
/** 拖拽落位:在全量 sorted 顺序里移位,再按 (i+1)*10 重写全部 sort(过滤态也不串序) */
async function onDrop(targetId?: number) {
const from = dragFrom.value;
dragFrom.value = null;
dragTo.value = null;
if (from == null || targetId == null || from === targetId) return;
const sorted = [...items.value].sort(
(a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0) || (Number(a.id) || 0) - (Number(b.id) || 0)
);
const fromIdx = sorted.findIndex(x => x.id === from);
const toIdx = sorted.findIndex(x => x.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = sorted.splice(fromIdx, 1);
sorted.splice(toIdx, 0, moved);
const changed: WidgetCatalog[] = [];
sorted.forEach((row, i) => {
const next = (i + 1) * 10;
if ((Number(row.sort) || 0) !== next) {
row.sort = next;
changed.push(row);
}
});
items.value = sorted;
if (changed.length === 0) return;
try {
for (const row of changed) await saveOne(row, {});
Message.success("排序已保存");
} catch (e) {
console.warn("[widget] 排序保存失败", e);
Message.error(typeof e === "string" ? e : "排序保存失败");
}
await loadList();
}
/**
* 单行保存:超管写全局目录(整行回传,避免 JPA 合并把未传字段置空);
* 校管写本校覆盖(schId 由后端取登录校,前端不传)。
* 注:校管侧四个覆盖字段恒回传当前生效值,等价于「本校显式固化」,值不产生可见漂移。
*/
async function saveOne(item: WidgetCatalog, patch: { enabled?: number; sort?: number }) {
if (isSuperView.value) {
await UserSettingApi.saveWidgetCatalog({ ...item, ...patch });
return;
}
await UserSettingApi.saveWidgetSchoolSetting({
widgetId: item.id,
enabled: patch.enabled !== undefined ? patch.enabled : item.enabled,
roleTypes: item.roleTypes ?? null,
sortNo: patch.sort !== undefined ? patch.sort : item.sort ?? 0,
alias: item.alias ?? null,
});
}
/** 空态引导:把前端注册表里有、候选池里没有的内置键一次登记进目录 */
async function quickRegisterBuiltins() {
const pending = registryCodes.filter(code => !registeredCodes.value.includes(code));
if (pending.length === 0) {
Message.info("内置组件已全部登记");
return;
}
registering.value = true;
let base = nextSort.value;
let done = 0;
for (const code of pending) {
try {
await UserSettingApi.saveWidgetCatalog({
id: null,
code,
name: code,
compType: 1,
content: null,
dataCode: code,
scopeType: 0,
scopeSchIds: [],
roleTypes: null,
roleAlias: null,
sort: base,
enabled: 1,
});
done++;
base += 10;
} catch (e) {
console.warn("[widget] 登记失败", code, e);
Message.error(typeof e === "string" ? e : `登记失败:${code}`);
}
}
registering.value = false;
if (done > 0) {
Message.success(`已登记 ${done} 个内置组件`);
await loadList();
}
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary; // 品牌主色唯一来源 = 项目 SCSS 变量
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
.widget-manage {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-line2: #e5e8ee;
min-width: 0;
}
.wm-viewbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.grow {
flex: 1;
}
.vb-tip {
font-size: 11.5px;
color: var(--wm-t4);
white-space: nowrap;
}
}
.role-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: $wm-brand;
background: $wm-brand-weak;
}
.seg-tabs {
display: inline-flex;
background: #f0f2f6;
border-radius: 9px;
padding: 3px;
gap: 2px;
.sg {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 13px;
border-radius: 7px;
color: var(--wm-t3);
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: all 0.15s;
&:hover {
color: $wm-brand;
}
&.active {
background: #fff;
color: $wm-brand;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
b {
font-size: 11px;
font-weight: 600;
color: #8a92a0;
background: #e8ebf1;
border-radius: 8px;
padding: 0 6px;
}
&.active b {
color: $wm-brand;
background: $wm-brand-weak;
}
}
}
.note-bar {
display: flex;
gap: 8px;
align-items: flex-start;
background: #f7f9fc;
border: 1px dashed var(--wm-line2);
color: var(--wm-t3);
padding: 9px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.65;
margin: 0 0 12px;
b {
color: var(--wm-t2);
font-weight: 600;
}
code {
background: #eef1f6;
border-radius: 4px;
padding: 1px 5px;
font-size: 11.5px;
}
&.warn {
background: #fffaf3;
border-color: #f5dfc0;
color: #8a5a17;
b {
color: #7a4d10;
}
}
&.key {
background: #f3fbf6;
border-color: #cfe9d9;
color: #2c6b47;
b {
color: #1d5537;
}
code {
background: #e4f4ea;
color: #1d5537;
}
}
}
.grid-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.tb-right {
display: flex;
align-items: center;
gap: 10px;
}
.search {
position: relative;
display: inline-flex;
align-items: center;
svg {
position: absolute;
left: 9px;
width: 14px;
height: 14px;
color: var(--wm-t4);
pointer-events: none;
}
input {
height: 30px;
width: 210px;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 0 10px 0 28px;
font-size: 12.5px;
outline: none;
transition: all 0.18s;
&:focus {
border-color: $wm-brand;
box-shadow: 0 0 0 3px rgba($wm-brand, 0.12);
}
}
}
.wgrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(252px, 1fr));
gap: 14px;
margin-bottom: 12px;
}
.wm-loading {
padding: 40px 0;
text-align: center;
font-size: 13px;
color: var(--wm-t4);
}
.wcard.add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 168px;
border-style: dashed;
color: var(--wm-t3);
font-size: 13px;
cursor: pointer;
transition: all 0.16s;
.plus {
font-size: 26px;
line-height: 1;
color: #c4c9d2;
}
&:hover {
border-color: $wm-brand;
color: $wm-brand;
background: $wm-brand-weak;
.plus {
color: $wm-brand;
}
}
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 44px 20px 48px;
border: 1px dashed var(--wm-line2);
border-radius: 10px;
.es-ic {
display: flex;
justify-content: center;
line-height: 0;
}
h5 {
font-size: 14.5px;
color: var(--wm-t1);
margin: 10px 0 6px;
}
p {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.8;
max-width: 560px;
margin: 0 auto 16px;
code {
background: #f2f4f8;
border-radius: 4px;
padding: 1px 5px;
color: #b42318;
font-size: 11.5px;
}
}
.es-actions {
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
}
.es-dim {
font-size: 12.5px;
color: var(--wm-t4);
}
&.slim {
padding: 34px 20px;
border: none;
p {
margin: 0;
}
}
}
.btn {
border: 1px solid #d0d5dd;
background: #fff;
color: #344054;
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 12.5px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
&:hover {
border-color: $wm-brand;
color: $wm-brand;
}
&.primary {
background: $wm-brand;
border-color: $wm-brand;
color: #fff;
&:hover {
background: color.adjust($wm-brand, $lightness: -6%);
color: #fff;
}
}
&.sm {
height: 30px;
padding: 0 10px;
font-size: 12.5px;
}
&[disabled] {
opacity: 0.5;
cursor: default;
}
}
</style>
+342
View File
@@ -0,0 +1,342 @@
<template>
<Teleport to="body">
<div v-if="visible" class="wm-reg-root">
<div class="modal-mask show" @click="close"></div>
<div class="modal show sm">
<div class="m-head">
<div>
<h4>登记内置组件 <span class="badge builtin">超管专属</span></h4>
<div class="m-sub">键须与 front/src/components/widgets/index.ts 一致</div>
</div>
<button class="x" @click="close">×</button>
</div>
<div class="m-body">
<p>
<b>原理:</b>内置组件的<b>代码</b>打在前端工程里,并在注册表 <code>index.ts</code> 注册成一个<b>键</b>;
<code>widget_catalog</code> 只存「目录行」(键 + 展示名 + 排序 + 启用 + 范围 + 角色 + 别名覆盖),<b>不存内容</b>。
用户端拿到候选池后按 <code>code</code> 去注册表查,命中就渲染对应 Vue 组件。<br />
所以「登记」= 往目录表插一条 <code>comp_type=1, code=注册表里的键</code> 的记录,<b>不会让代码里凭空多出一个组件</b>。
</p>
<ol>
<li>前端新建 <code>components/widgets/XxxWidget.vue</code></li>
<li>在 <code>components/widgets/index.ts</code> 注册 <code>{ code, name }</code>(键一旦发布就别改,改键 = 换身份)</li>
<li>需要动态数据时,后端 <code>WidgetDataService</code> 加白名单分支</li>
<li><b>发版部署</b>(内置组件必须发版才生效)</li>
<li>回到本弹窗点「登记」,或跑 <code>resources/sql/widget_catalog.sql</code> 种子</li>
</ol>
<ul class="reg-list">
<li v-for="row in rows" :key="row.code">
<input v-model="row.name" class="inp name-inp" maxlength="30" placeholder="展示名" />
<span class="rc">{{ row.code }}</span>
<span class="grow"></span>
<span v-if="row.registered" class="badge ok">已登记</span>
<button v-else class="btn sm primary" :disabled="saving" @click="registerOne(row)">登记</button>
</li>
<li v-if="rows.length === 0" class="empty">前端注册表里没有可登记的键</li>
</ul>
</div>
<div class="m-foot">
<span class="save-tip">{{ tip }}</span>
<button class="btn plain" @click="close">关闭</button>
<button v-if="pendingCount > 1" class="btn primary" :disabled="saving" @click="registerAll">
一键登记 {{ pendingCount }} 个未登记键
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import UserSettingApi from "@/api/UserSettingApi";
const props = defineProps<{
visible: boolean;
/** 前端注册表的键(来自 components/widgets/index.ts) */
registryCodes: string[];
/** 已登记进候选池的内置键 */
registeredCodes: string[];
/** 登记时的起始排序基数 */
nextSort: number;
}>();
const emit = defineEmits<{ (e: "close"): void; (e: "saved"): void }>();
const saving = ref(false);
const tip = ref("");
const rows = reactive<{ code: string; name: string; registered: boolean }[]>([]);
const pendingCount = computed(() => rows.filter(x => !x.registered).length);
watch(
() => props.visible,
show => {
if (!show) return;
rows.splice(0, rows.length);
props.registryCodes.forEach(code => {
rows.push({ code, name: code, registered: props.registeredCodes.includes(code) });
});
tip.value = "";
},
{ immediate: true }
);
function close() {
emit("close");
}
async function registerOne(row: { code: string; name: string }) {
await registerBatch([{ code: row.code, name: row.name }]);
}
async function registerAll() {
const payload = rows.filter(x => !x.registered).map(x => ({ code: x.code, name: x.name }));
await registerBatch(payload);
}
async function registerBatch(payload: { code: string; name: string }[]) {
if (payload.length === 0) return;
saving.value = true;
let done = 0;
let baseSort = props.nextSort;
for (const one of payload) {
try {
await UserSettingApi.saveWidgetCatalog({
id: null,
code: one.code,
name: (one.name || one.code).trim(),
compType: 1,
content: null,
dataCode: one.code,
scopeType: 0,
scopeSchIds: [],
roleTypes: null,
roleAlias: null,
sort: baseSort,
enabled: 1,
});
done++;
baseSort += 10;
} catch (e) {
console.warn("[widget] 登记失败", one.code, e);
Message.error(typeof e === "string" ? e : `登记失败:${one.code}`);
}
}
saving.value = false;
if (done > 0) {
Message.success(`已登记 ${done} 个内置组件`);
tip.value = "登记后进入候选池,可回主界面继续配范围 / 角色";
emit("saved");
}
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary;
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
.wm-reg-root {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-ok-bg: #e8f7ee;
--wm-ok: #12a150;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(16, 24, 40, 0.3);
z-index: 110;
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 780px;
max-width: 94vw;
max-height: 88vh;
background: #fff;
border-radius: 12px;
z-index: 111;
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.24);
display: flex;
flex-direction: column;
overflow: hidden;
&.sm {
width: 560px;
}
}
.m-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wm-line);
h4 {
font-size: 15px;
font-weight: 600;
color: var(--wm-t1);
display: flex;
align-items: center;
gap: 8px;
}
.m-sub {
font-size: 12px;
color: var(--wm-t4);
margin-top: 4px;
font-family: Consolas, Menlo, monospace;
}
.x {
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
color: var(--wm-t4);
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
&:hover {
background: #f2f4f8;
color: var(--wm-t2);
}
}
}
.m-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 22px 18px;
> p {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.75;
margin-bottom: 12px;
}
ol {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.9;
padding-left: 20px;
margin-bottom: 12px;
b {
color: var(--wm-t2);
}
}
}
.m-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 22px 16px;
border-top: 1px solid var(--wm-line);
.save-tip {
flex: 1;
font-size: 12px;
color: var(--wm-ok);
}
}
.badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: $wm-brand-weak;
color: $wm-brand;
}
&.ok {
background: var(--wm-ok-bg);
color: var(--wm-ok);
}
}
.reg-list {
list-style: none;
border: 1px solid var(--wm-line);
border-radius: 8px;
overflow: hidden;
li {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border-bottom: 1px solid var(--wm-line);
font-size: 13px;
&:last-child {
border-bottom: none;
}
&.empty {
color: var(--wm-t4);
font-size: 12.5px;
}
.grow {
flex: 1;
}
.rc {
font-size: 11.5px;
color: var(--wm-t4);
font-family: Consolas, Menlo, monospace;
}
}
}
.inp {
height: 30px;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 0 10px;
font-size: 12.5px;
outline: none;
background: #fff;
&:focus {
border-color: $wm-brand;
box-shadow: 0 0 0 3px rgba($wm-brand, 0.12);
}
&.name-inp {
width: 150px;
flex-shrink: 0;
}
}
.btn {
border: 1px solid #d0d5dd;
background: #fff;
color: #344054;
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 12.5px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
&:hover {
border-color: var(--wm-t4);
}
&.primary {
background: $wm-brand;
border-color: $wm-brand;
color: #fff;
}
&.sm {
height: 26px;
padding: 0 10px;
font-size: 12px;
}
&[disabled] {
opacity: 0.5;
cursor: default;
}
}
</style>