- 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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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 |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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+'¶ms='+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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user