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
+79 -19
View File
@@ -68,7 +68,7 @@
trigger="custom"
>
<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 +79,7 @@
v-if="!!item.checked"
class="fr mt-3"
name="widget-checked"
color="#2d8cf0"
color="currentColor"
size="14"
></SvgIcon>
</div>
@@ -90,7 +90,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>
@@ -108,27 +108,41 @@
hoverColor="#646566"
/>
</div>
<Skeleton
style="padding: 15px"
:loading="item.loading"
:title="false"
:paragraph="{ rows: 4 }"
animated
<!-- ① front 工程内置组件:内容在代码库,不走 iframe -->
<component
v-if="item.compType === 1 && widgetRegistry[item.code]"
:is="widgetRegistry[item.code]"
:widget-id="item.id"
@loaded="onWidgetLoaded(item.id, $event)"
/>
<iframe
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"
class="widget-app-iframe"
></iframe>
<template v-else>
<Skeleton
style="padding: 15px"
:loading="item.loading"
:title="false"
: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)"
: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>
<SvgIcon name="widget-empty" color="currentColor" size="120"></SvgIcon>
<h4>单击 + 按钮可添加小工具。</h4>
</div>
</template>
@@ -147,8 +161,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,
@@ -269,6 +285,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 +419,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);