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 正序渲染)
This commit is contained in:
chenxing
2026-10-11 08:50:49 +08:00
parent be0dac6bfa
commit f5faa29018
15 changed files with 1150 additions and 39 deletions
+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>"
);
}