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
+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
+22
View File
@@ -92,6 +92,28 @@ 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<any[]>({ url });
}
saveWidgetCatalog(catalog: Record<string, any>) {
const url = path + "/SaveWidgetCatalog";
return this.postRequest<any>({ url, data: catalog });
}
}
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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 350 B

+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

+38 -6
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) {
@@ -36,6 +54,14 @@
&: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 {
@@ -59,11 +85,12 @@
}
}
// 拖拽态加深 + 加强模糊(旧版翻白 → 浅灰图标压浅白,是这轮修掉的反向翻车)
&__drag {
.widget-header {
background: rgba(255, 255, 255, 0.5);
}
background: rgba(255, 255, 255, 0.6);
background: rgba(15, 23, 42, 0.56);
backdrop-filter: blur(22px) saturate(150%);
-webkit-backdrop-filter: blur(22px) saturate(150%);
box-shadow: 0 14px 44px rgba(0, 0, 0, 0.38);
}
}
@@ -73,7 +100,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;
+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>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="wg-syllabus" ref="refRoot">
<Skeleton
v-if="loading"
style="padding: 15px"
:loading="true"
:title="false"
:paragraph="{ rows: 4 }"
animated
/>
<div v-else-if="grid.length > 0" class="table-wrap">
<table class="my-table">
<tr>
<th>节</th>
<th v-for="week in weekCols" :key="week.Weeks">{{ week.weekName }}</th>
</tr>
<tr v-for="row in grid" :key="row.label">
<td class="sec">{{ row.label }}</td>
<td v-for="(cell, index) in row.cells" :key="index">
<template v-if="cell">
<div class="cla">{{ cell.ClassName }}</div>
<div class="sub">{{ cell.SubName }}</div>
</template>
</td>
</tr>
</table>
</div>
<div v-else class="no-data">暂无数据</div>
</div>
</template>
<script lang="ts" setup>
import { computed, 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;
/** 表头星期(按 Weeks 去重升序),口径同旧 syllabus_index.js weekArr */
const weekCols = computed(() => {
const nameByKey: Record<number, string> = {};
list.value.forEach(x => {
if (nameByKey[x.Weeks] === undefined) nameByKey[x.Weeks] = x.weekName;
});
return Object.keys(nameByKey)
.map(key => ({ Weeks: Number(key), weekName: nameByKey[Number(key)] }))
.sort((a, b) => a.Weeks - b.Weeks);
});
/** 行节次(按 CourseTimes 去重升序),口径同旧 courseArr */
const courseRows = computed(() => {
const nameByKey: Record<number, string> = {};
list.value.forEach(x => {
if (nameByKey[x.CourseTimes] === undefined) nameByKey[x.CourseTimes] = x.CourseName;
});
return Object.keys(nameByKey)
.map(key => ({ CourseTimes: Number(key), CourseName: nameByKey[Number(key)] }))
.sort((a, b) => a.CourseTimes - b.CourseTimes);
});
const grid = computed(() => {
const cellByKey: Record<string, any> = {};
list.value.forEach(x => {
cellByKey[`${x.CourseTimes}-${x.Weeks}`] = x;
});
return courseRows.value.map(course => ({
label: (course.CourseName || "").replace("第", "").replace("节", ""),
cells: weekCols.value.map(week => cellByKey[`${course.CourseTimes}-${week.Weeks}`]),
}));
});
onMounted(async () => {
if (window.ResizeObserver && refRoot.value) {
observer = new ResizeObserver(reportHeight);
observer.observe(refRoot.value);
}
try {
const data: any = await UserSettingApi.getWidgetData("syllabus", "");
list.value = Array.isArray(data) ? data : [];
} catch (e) {
console.warn("[widget] syllabus 取数失败", e);
}
loading.value = false;
reportHeight();
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
function reportHeight() {
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
}
</script>
<style lang="scss" scoped>
.wg-syllabus {
padding: 4px 0 10px;
}
.table-wrap {
padding: 0 15px;
}
.my-table {
width: 100%;
border-collapse: collapse;
th {
line-height: 20px;
padding: 8px 0;
font-weight: 700;
font-size: 12px;
color: #6e7c87;
}
td {
border-top: 1px solid #f7f7f7;
line-height: 15px;
padding: 8px 0;
text-align: center;
font-size: 12px;
}
td.sec {
color: #6e7c87;
font-weight: 700;
}
.cla {
color: #344449;
}
.sub {
color: #98a2b0;
}
}
.no-data {
min-height: 90px;
display: flex;
align-items: center;
justify-content: center;
color: #b9c2cc;
font-size: 13px;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div class="wg-weekplan" ref="refRoot">
<Skeleton
v-if="loading"
style="padding: 15px"
:loading="true"
:title="false"
:paragraph="{ rows: 4 }"
animated
/>
<template v-else-if="days.length > 0">
<div class="wk-title">
<span>{{ titleStr }}</span>
<span class="pager">
<i :class="{ disabled: iweek <= -1 }" @click="onPrev">‹</i>
<i :class="{ disabled: iweek >= 1 }" @click="onNext">›</i>
</span>
</div>
<div class="wk-days">
<div
v-for="(day, index) in days"
:key="day.date"
:class="['d', { 'is-on': index === currentIndex, has: hasWork(index) }]"
@click="onDayClick(index)"
>
<span>{{ WeekArr[index] }}</span>
<b>{{ dayDate(day.date) }}</b>
</div>
</div>
<div class="wk-tips">日常详情</div>
<ul v-if="currentList.length > 0" class="wk-list">
<li v-for="(item, index) in currentList" :key="index">
<span class="content" :title="item.contents">{{ item.contents }}</span>
<span class="time">{{ item.times }}</span>
</li>
</ul>
<div v-else class="no-data">暂无工作安排</div>
</template>
<div v-else class="no-data">暂无数据</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import UserSettingApi from "@/api/UserSettingApi";
defineProps<{ widgetId: number }>();
const emit = defineEmits(["loaded"]);
const WeekArr = ["一", "二", "三", "四", "五", "六", "日"];
const refRoot = ref<HTMLElement | null>(null);
const loading = ref(true);
const days = ref<any[]>([]);
const otherData = ref<any>({});
const iweek = ref(0);
const currentIndex = ref(0);
let observer: ResizeObserver | null = null;
const titleStr = computed(() => {
if (days.value.length === 0) return "";
const start = formatMonthDay(days.value[0].date);
const end = formatMonthDay(days.value[days.value.length - 1].date);
return `${start}~${end} 第${otherData.value.week || 0}周`;
});
const currentList = computed(() => {
const day = days.value[currentIndex.value];
return day && day.list ? day.list : [];
});
onMounted(async () => {
if (window.ResizeObserver && refRoot.value) {
observer = new ResizeObserver(reportHeight);
observer.observe(refRoot.value);
}
currentIndex.value = (new Date().getDay() + 6) % 7;
await loadWeek(0);
});
onUnmounted(() => {
if (observer) observer.disconnect();
});
async function loadWeek(week: number) {
iweek.value = week;
loading.value = true;
try {
const data: any = await UserSettingApi.getWidgetData("weekplan", "iweek=" + week);
days.value = (data && data.Data) || [];
otherData.value = (data && data.OtherData) || {};
if (currentIndex.value >= days.value.length) currentIndex.value = 0;
} catch (e) {
console.warn("[widget] weekplan 取数失败", e);
}
loading.value = false;
reportHeight();
}
function onPrev() {
if (iweek.value <= -1) return;
loadWeek(iweek.value - 1);
}
function onNext() {
if (iweek.value >= 1) return;
loadWeek(iweek.value + 1);
}
function onDayClick(index: number) {
currentIndex.value = index;
}
function hasWork(index: number) {
const day = days.value[index];
return !!day && !!day.list && day.list.length > 0;
}
function dayDate(date: string) {
return (date || "").slice(8, 10);
}
function formatMonthDay(date: string) {
return (date || "").slice(5, 7) + "月" + (date || "").slice(8, 10) + "日";
}
function reportHeight() {
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
}
</script>
<style lang="scss" scoped>
.wg-weekplan {
padding: 6px 0 10px;
}
.wk-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 15px;
padding: 6px 0 10px;
font-size: 14px;
font-weight: 700;
color: #344449;
.pager i {
font-style: normal;
font-size: 16px;
font-weight: 400;
color: #6e7c87;
padding: 0 6px;
cursor: pointer;
&.disabled {
color: #d6dbe1;
cursor: default;
}
}
}
.wk-days {
display: flex;
margin: 0 15px 6px;
gap: 4px;
.d {
flex: 1 1 0;
text-align: center;
font-size: 12px;
color: #6e7c87;
border-radius: 4px;
padding: 4px 0;
cursor: pointer;
b {
display: block;
font-size: 13px;
color: #344449;
margin-top: 2px;
font-weight: 600;
}
&.is-on {
background: #eef4ff;
color: #1d4ed8;
}
&.has b::after {
content: "";
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: #2d8cf0;
margin: 2px auto 0;
}
}
}
.wk-tips {
margin: 8px 15px 0;
font-size: 12px;
color: #6e7c87;
}
.wk-list {
list-style: none;
margin: 0;
padding: 0;
li {
display: flex;
align-items: center;
margin: 0 15px;
padding: 5px 0;
border-bottom: 1px solid #f2f4f7;
font-size: 12.5px;
color: #344449;
&:last-child {
border-bottom: 0;
}
}
.content {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex: 0 0 auto;
color: #6e7c87;
margin-left: 10px;
}
}
.no-data {
min-height: 90px;
display: flex;
align-items: center;
justify-content: center;
color: #b9c2cc;
font-size: 13px;
}
</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")),
};
+2 -1
View File
@@ -127,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;
+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 -2
View File
@@ -591,6 +591,15 @@
</Table>
</div>
</div>
<!-- 小组件管理(内置化改造轮 2026-10-09,仅管理员:候选池键/展示名 + 自定义片段正文) -->
<div v-else-if="curPane === 'widget'" class="pane">
<div class="pane-head">
<h3>小组件管理</h3>
<p>维护桌面小工具面板的候选池:内置组件只登记键与展示名,自定义片段在此贴 HTML 或上传 .html 文件(内容存库,全链路没有 html 地址)。</p>
</div>
<WidgetManage />
</div>
</div>
</div>
@@ -714,6 +723,7 @@ import { useRoute, useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SysSetting from "@/views/SysSetting.vue";
import WidgetManage from "@/views/widget/WidgetManage.vue";
import BaseApi from "@/api/BaseApi";
import SysSettingApi from "@/api/SysSettingApi";
import UserSettingApi from "@/api/UserSettingApi";
@@ -752,8 +762,8 @@ const isSuper = computed(() => store.userInfo?.type == 1);
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错)。
* 三层化(S4)后「头像/主题/壁纸」所有身份都可用——每角色只编辑自己那一层,不再是管理项 */
const ADMIN_PANES = ["logo", "sidebar", "sys", "statset"];
const ALL_PANES = ["profile", "password", "avatar", "theme", "wallpaper", "desktop", "logo", "sidebar", "sys", "statset", "noperm"];
const ADMIN_PANES = ["logo", "sidebar", "sys", "statset", "widget"];
const ALL_PANES = ["profile", "password", "avatar", "theme", "wallpaper", "desktop", "logo", "sidebar", "sys", "statset", "widget", "noperm"];
/** 历史 ?pane= 键兼容(S4 重命名:mytheme/schooltheme→theme、mywallpaper→wallpaper),保证旧链接不 404/不白屏 */
const LEGACY_PANE_KEYS: Record<string, string> = {
@@ -815,6 +825,7 @@ const NAV_PORTAL: NavItem[] = [
const NAV_SYSTEM: NavItem[] = [
{ key: "sys", name: "系统设置", icon: svgWrap('<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>') },
{ key: "statset", name: "统计设置", icon: svgWrap('<path d="M18 20V10M12 20V4M6 20v-6"/>') },
{ key: "widget", name: "小组件管理", icon: svgWrap('<rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/>') },
];
/** 分组导航(S4 按原型重排:账号 / 个性化 / 门户外观(仅管理员) / 系统(仅管理员));组级过滤即可,
+65 -5
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,6 +108,14 @@
hoverColor="#646566"
/>
</div>
<!-- ① front 工程内置组件:内容在代码库,不走 iframe -->
<component
v-if="item.compType === 1 && widgetRegistry[item.code]"
:is="widgetRegistry[item.code]"
:widget-id="item.id"
@loaded="onWidgetLoaded(item.id, $event)"
/>
<template v-else>
<Skeleton
style="padding: 15px"
:loading="item.loading"
@@ -115,20 +123,26 @@
: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>
<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);
+417
View File
@@ -0,0 +1,417 @@
<template>
<div class="widget-manage">
<aside class="wm-side">
<div class="wm-side-hd">
<h4>小组件</h4>
<button class="btn primary sm" @click="onNew">新增自定义片段</button>
</div>
<div v-if="loading" class="wm-empty">加载中…</div>
<template v-else>
<div class="wm-group">内置组件(内容在 front 工程)</div>
<ul class="wm-list">
<li
v-for="item in builtinList"
:key="item.id"
:class="{ on: selectedId === item.id }"
@click="onSelect(item)"
>
<span>{{ item.name }}</span>
<em>{{ item.enabled === 1 ? "启用" : "已下线" }}</em>
</li>
</ul>
<div class="wm-group">自定义片段(内容在 widget_catalog.content)</div>
<ul class="wm-list">
<li
v-for="item in fragmentList"
:key="item.id"
:class="{ on: selectedId === item.id }"
@click="onSelect(item)"
>
<span>{{ item.name }}</span>
<em class="custom">{{ item.enabled === 1 ? "启用" : "已下线" }}</em>
</li>
<li v-if="fragmentList.length === 0" class="empty">暂无自定义片段</li>
</ul>
</template>
<p class="wm-tip">
两条路都<b>不存在 html 地址</b>:内置=前端注册表键 <code>{{ sampleCode }}</code>,
自定义=库里 <code>content</code> 正文。按校下发(<code>sch_id</code>)本期只预留字段,留空即全校可见。
</p>
</aside>
<section class="wm-main">
<div class="wm-form">
<label class="fld">
<span>名称</span>
<Input v-model="form.name" maxlength="30" placeholder="面板标题显示名" />
</label>
<label class="fld">
<span>组件键 code</span>
<Input v-model="form.code" :disabled="isLockedCode" placeholder="如 evaluate / notice_bar" />
</label>
<label class="fld">
<span>类型</span>
<Select v-model="form.compType" :disabled="!!form.id" class="wm-select">
<Option :value="1">内置组件(comp_type=1)</Option>
<Option :value="2">自定义 HTML 片段(comp_type=2)</Option>
</Select>
</label>
<label class="fld">
<span>取数键 data_code</span>
<Input v-model="form.dataCode" placeholder="片段内 yhWidget.getData 走它" />
</label>
<label class="fld fld-sm">
<span>排序</span>
<InputNumber v-model="form.sort" :min="0" />
</label>
<label class="fld fld-sm">
<span>启用</span>
<i-switch v-model="form.enabled" :true-value="1" :false-value="0" />
</label>
<label class="fld fld-sm">
<span>可见范围 sch_id</span>
<InputNumber v-model="form.schId" :min="1" placeholder="留空=全校" />
</label>
</div>
<div class="wm-tools">
<label class="btn file-btn">
上传 .html
<input type="file" accept=".html,.htm" @change="onFileChange" />
</label>
<span class="grow"></span>
<span class="save-tip">{{ saveTip }}</span>
<button class="btn primary" :disabled="!canSave || saving" @click="onSave">保存</button>
</div>
<div class="wm-editor" :class="{ 'is-locked': isBuiltin }">
<Codemirror
v-model="form.content"
:extensions="extensions"
:disabled="isBuiltin"
:style="{ height: '100%' }"
:indent-with-tab="true"
placeholder="请选择左侧片段,或粘贴 HTML 正文"
/>
</div>
<p class="wm-foot">
内置组件的内容在 <code>front/src/components/widgets/</code> 里,此处只登记键与展示名,编辑器置灰;
上传走 <code>FileReader</code> 读成文本填入编辑器,<b>不落地成文件、不产生任何 URL</b>。
</p>
</section>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from "vue";
import { Codemirror } from "vue-codemirror";
import { html } from "@codemirror/lang-html";
import { EditorView } from "@codemirror/view";
import { Message } from "view-ui-plus";
import UserSettingApi from "@/api/UserSettingApi";
const list = ref<any[]>([]);
const loading = ref(true);
const saving = ref(false);
const selectedId = ref<number | null>(null);
const saveTip = ref("");
const sampleCode = "evaluate";
const form = reactive<any>({
id: null,
code: "",
name: "",
compType: 2,
content: "",
dataCode: "",
schId: null,
sort: 0,
enabled: 1,
});
const builtinList = computed(() => list.value.filter(x => x.compType === 1));
const fragmentList = computed(() => list.value.filter(x => x.compType === 2));
const isBuiltin = computed(() => form.compType === 1);
/** 已入库的内置项不许改键:键是前端注册表的索引 */
const isLockedCode = computed(() => !!form.id && isBuiltin.value);
const canSave = computed(() => !!form.code.trim() && !!form.name.trim());
const extensions = computed(() => [html(), EditorView.lineWrapping]);
onMounted(loadList);
async function loadList() {
loading.value = true;
try {
list.value = (await UserSettingApi.getWidgetCatalogList()) || [];
} catch (e) {
console.warn("[widget] 候选池列表拉取失败", e);
list.value = [];
}
loading.value = false;
}
function onSelect(item: any) {
selectedId.value = item.id;
form.id = item.id;
form.code = item.code;
form.name = item.name;
form.compType = item.compType;
form.content = item.content || "";
form.dataCode = item.dataCode || "";
form.schId = item.schId;
form.sort = item.sort || 0;
form.enabled = item.enabled === 0 ? 0 : 1;
saveTip.value = isBuiltin.value
? `内置组件(${item.code}):内容在 front 工程里,此处只登记键与展示名`
: "自定义片段:可编辑 / 上传 / 保存";
}
function onNew() {
selectedId.value = null;
form.id = null;
form.code = "";
form.name = "";
form.compType = 2;
form.content = "";
form.dataCode = "";
form.schId = null;
form.sort = (list.value.length + 1) * 10;
form.enabled = 1;
saveTip.value = "新建自定义片段:填组件键与名称后保存";
}
async function onSave() {
if (!canSave.value) return;
saving.value = true;
try {
await UserSettingApi.saveWidgetCatalog({
id: form.id,
code: form.code.trim(),
name: form.name.trim(),
compType: form.compType,
content: isBuiltin.value ? null : form.content,
dataCode: form.dataCode ? form.dataCode.trim() : null,
schId: form.schId,
sort: form.sort,
enabled: form.enabled,
});
Message.success("保存成功");
saveTip.value = "已保存到 widget_catalog";
await loadList();
const find = list.value.find(x => x.code === form.code.trim());
if (find) onSelect(find);
} catch (e) {
console.warn("[widget] 保存失败", e);
Message.error(typeof e === "string" ? e : "保存失败");
}
saving.value = false;
}
/** 上传只在浏览器里读成文本填编辑器,不请求任何上传接口 */
function onFileChange(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
form.content = String(reader.result || "");
saveTip.value = `已读入 ${file.name}(${file.size} B),保存后生效`;
};
reader.readAsText(file, "utf-8");
input.value = "";
}
</script>
<style lang="scss" scoped>
.widget-manage {
display: flex;
gap: 16px;
align-items: flex-start;
}
.wm-side {
width: 250px;
flex: 0 0 auto;
background: #fff;
border: 1px solid #e5e8ee;
border-radius: 10px;
padding: 14px;
.wm-side-hd {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
h4 {
font-size: 13px;
color: #667085;
font-weight: 600;
}
}
.wm-group {
font-size: 12px;
color: #98a2b0;
margin: 10px 0 4px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #344054;
&:hover {
background: #f5f7fa;
}
&.on {
background: #eaf1ff;
color: #5979ea;
}
&.empty {
color: #999;
cursor: default;
}
em {
font-style: normal;
font-size: 11px;
color: #98a2b0;
&.custom {
color: #b54708;
}
}
}
}
.wm-empty {
font-size: 13px;
color: #999;
padding: 8px 0;
}
.wm-tip {
margin-top: 12px;
font-size: 12px;
color: #98a2b0;
line-height: 1.7;
code {
background: #f2f4f8;
border-radius: 4px;
padding: 1px 5px;
color: #b42318;
}
}
}
.wm-main {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid #e5e8ee;
border-radius: 10px;
padding: 14px;
}
.wm-form {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-bottom: 12px;
.fld {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 190px;
> span {
font-size: 12px;
color: #667085;
}
&.fld-sm {
min-width: 130px;
}
}
.wm-select {
width: 100%;
}
}
.wm-tools {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.grow {
flex: 1;
}
.save-tip {
font-size: 12px;
color: #027a48;
}
}
.file-btn {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
}
.btn {
border: 1px solid #d0d5dd;
background: #fff;
color: #344054;
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 12.5px;
cursor: pointer;
&:hover {
border-color: #98a2b0;
}
&.primary {
background: #101828;
border-color: #101828;
color: #fff;
}
&.sm {
height: 26px;
padding: 0 10px;
font-size: 12px;
}
&[disabled] {
opacity: 0.5;
cursor: default;
}
}
.wm-editor {
height: 330px;
border: 1px solid #e8eaec;
border-radius: 6px;
overflow: hidden;
:deep(.cm-editor) {
height: 100%;
}
// 锁定态灰底走我们自己的类:vue-codemirror 的 disabled 不产生任何 class,只把 .cm-content 的
// contenteditable 置 false,所以按 .cm-disabled / .cm-not-editable 写选择器都会空挂
&.is-locked {
background: #f9fafb;
}
}
.wm-foot {
margin-top: 10px;
font-size: 12px;
color: #98a2b0;
line-height: 1.8;
code {
background: #f2f4f8;
border-radius: 4px;
padding: 1px 5px;
color: #b42318;
font-size: 11.5px;
}
}
</style>