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)
531 lines
19 KiB
Vue
531 lines
19 KiB
Vue
<template>
|
||
<div class="used-app-wrap">
|
||
<div class="used-app-list" ref="refApplist">
|
||
<template v-for="item in appList" :key="item.id + ''">
|
||
<ContextMenu
|
||
class="app-container"
|
||
:title="item.name"
|
||
@on-change="onMenuChange"
|
||
@select="onMenuSel"
|
||
:menu="[
|
||
{
|
||
label: '从桌面删除',
|
||
appId: item.id,
|
||
id: '1',
|
||
},
|
||
{
|
||
label: '新窗口打开',
|
||
appId: item.appId,
|
||
id: '2',
|
||
},
|
||
]"
|
||
>
|
||
<a @click="openLinkPage(item)">
|
||
<template v-if="item.type == 1">
|
||
<AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
|
||
:tint="item.iconColor" />
|
||
<img v-else :src="$utils.appIconPath(item.icon)" />
|
||
</template>
|
||
<img
|
||
v-else-if="item.type == 2"
|
||
:src="$conf.fileHost + '/image/lightapp.default.png'"
|
||
v-real-img="$conf.serviceHost + item.icon"
|
||
/>
|
||
|
||
<div class="app-name">{{ item.name }}</div>
|
||
</a>
|
||
</ContextMenu>
|
||
</template>
|
||
</div>
|
||
<vue-drag-resize
|
||
v-if="isDragShowComp"
|
||
:class="{ 'widget-wrap__drag': dragObj.isDrag }"
|
||
contentClass="widget-wrap"
|
||
:x="dragPositionObj.xaxis"
|
||
:y="dragPositionObj.yaxis"
|
||
:w="dragPositionObj.width"
|
||
:h="dragPositionObj.height"
|
||
:parentW="dragObj.parentW"
|
||
:parentH="dragObj.parentH"
|
||
:minh="248"
|
||
:z="10"
|
||
:isActive="true"
|
||
:parentLimitation="true"
|
||
:isDraggable="dragObj.isDrag"
|
||
:isResizable="true"
|
||
:preventActiveBehavior="true"
|
||
:stickSize="5"
|
||
:sticks="['tm', 'bm']"
|
||
@clicked="onDragChange"
|
||
@dragstop="onDragResizestop($event, 'drag')"
|
||
@resizestop="onDragResizestop($event, 'resize')"
|
||
>
|
||
<div class="widget-header">
|
||
<Dropdown
|
||
placement="bottom-start"
|
||
: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="currentColor" />
|
||
</div>
|
||
<template #list>
|
||
<DropdownMenu style="min-width: 120px; max-height: 200px; overflow-y: auto">
|
||
<DropdownItem :key="item.k" :name="item.k" v-for="item in widgetSel">
|
||
<div class="fix">
|
||
<div class="fl">{{ item.v }}</div>
|
||
<SvgIcon
|
||
v-if="!!item.checked"
|
||
class="fr mt-3"
|
||
name="widget-checked"
|
||
color="currentColor"
|
||
size="14"
|
||
></SvgIcon>
|
||
</div>
|
||
</DropdownItem>
|
||
</DropdownMenu>
|
||
</template>
|
||
</Dropdown>
|
||
|
||
<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="currentColor" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="widget-body" v-show="!dragObj.isDrag">
|
||
<vuescroll :ops="widgetOps" v-if="widgetAppList.length > 0">
|
||
<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"
|
||
name="widget-close"
|
||
size="18"
|
||
color="#999999"
|
||
hoverColor="#646566"
|
||
/>
|
||
</div>
|
||
</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"
|
||
: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="currentColor" size="120"></SvgIcon>
|
||
<h4>单击 + 按钮可添加小工具。</h4>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</vue-drag-resize>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import ContextMenu from "@/components/ContextMenu.vue";
|
||
import AppIcon from "@/components/AppIcon.vue";
|
||
import { appiconKeyOf } from "@/constants/appIconRules";
|
||
import { ref, reactive, onMounted, onUnmounted, computed, watch, watchEffect, nextTick } from "vue";
|
||
import BaseApi from "@/api/BaseApi";
|
||
// @ts-ignore
|
||
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,
|
||
BschTransferRoleInfo,
|
||
IframePageParmas,
|
||
LinkOpenTypeEnum,
|
||
StateEnum,
|
||
WidgetPosition,
|
||
WidgetPositionResp,
|
||
} from "@/types";
|
||
import { bschRoleEncode } from "@/utils/bschRoleEncode";
|
||
import { useRouter, useRoute } from "vue-router";
|
||
import mittBus from "@/utils/mitt";
|
||
import vuescroll from "vuescroll";
|
||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||
import { Message } from "view-ui-plus";
|
||
import { useDraggable } from "vue-draggable-plus";
|
||
|
||
const { $conf, $utils } = useCurrentInstance();
|
||
const store = useDefaultStore();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
/**
|
||
* 矢量渲染判定(方案 §5.4 回退链,与 AppList.vue / SearchPanel.vue 同口径):
|
||
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
|
||
*/
|
||
function vectorIconOf(item: any): string {
|
||
if (item?.iconKey) return item.iconKey;
|
||
if (item?.icon) return "";
|
||
return appiconKeyOf(item?.name || "");
|
||
}
|
||
|
||
const refApplist = ref();
|
||
const isDragShow = ref(false),
|
||
isDesltopAppDefault = ref(false),
|
||
appList = ref<any[]>([]);
|
||
const dragObj = reactive({
|
||
isDrag: false,
|
||
selVisible: false,
|
||
parentW: 0,
|
||
parentH: 0,
|
||
});
|
||
|
||
const widgetOps = {
|
||
vuescroll: {},
|
||
scrollPanel: {},
|
||
rail: {},
|
||
bar: {
|
||
background: "#333",
|
||
},
|
||
};
|
||
const widgetAppList = ref<any[]>([]);
|
||
const dragPositionObj = reactive<Partial<WidgetPosition>>({});
|
||
|
||
const isDragShowComp = computed(() => {
|
||
const tempObj = cloneDeep(store.getWidgetDragObj);
|
||
return tempObj.isWidgetShow && isDragShow.value;
|
||
});
|
||
|
||
const compMenuId = computed(() => {
|
||
let menuList = store.curUserMenuList.find(x => x.url == route.path);
|
||
return menuList ? menuList.id : -1;
|
||
});
|
||
|
||
watchEffect(() => {
|
||
const tempObj = cloneDeep(store.getWidgetDragObj);
|
||
let isOutRangeX = false,
|
||
isOutRangeY = false; //是否超出范围
|
||
if (tempObj.xaxis == 0 && tempObj.yaxis === 0) {
|
||
tempObj.xaxis = dragObj.parentW - 540;
|
||
tempObj.yaxis = 88;
|
||
isOutRangeX = dragObj.parentW < 900;
|
||
} else {
|
||
isOutRangeX = dragObj.parentW < tempObj.width + tempObj.xaxis;
|
||
isOutRangeY = dragObj.parentH < tempObj.height + tempObj.yaxis;
|
||
}
|
||
if (isOutRangeX) tempObj.xaxis = dragObj.parentW - tempObj.width - 10;
|
||
if (isOutRangeY) tempObj.yaxis = dragObj.parentH - tempObj.height - 10;
|
||
Object.assign(dragPositionObj, tempObj);
|
||
reloadDragWrap();
|
||
});
|
||
|
||
const draggable = useDraggable(refApplist, appList, {
|
||
animation: 150,
|
||
onStart() {
|
||
// console.log("start");
|
||
},
|
||
async onUpdate() {
|
||
let ids = appList.value.map(x => x.id);
|
||
let appIds = appList.value.map(x => x.appId);
|
||
if (isDesltopAppDefault.value) {
|
||
await UserSettingApi.sortUserDesktopApp(appIds);
|
||
isDesltopAppDefault.value = false;
|
||
} else await UserSettingApi.sortUserDesktopApp(ids);
|
||
// console.log("update");
|
||
},
|
||
});
|
||
onMounted(async () => {
|
||
await getAppList();
|
||
dragObj.parentW = window.innerWidth;
|
||
dragObj.parentH = window.innerHeight;
|
||
window.addEventListener("resize", cancalDebounce);
|
||
window.addEventListener("message", iframeCallback);
|
||
});
|
||
onUnmounted(() => {
|
||
window.removeEventListener("resize", cancalDebounce);
|
||
window.removeEventListener("message", iframeCallback);
|
||
});
|
||
|
||
const widgetSel = computed(() => store.getWidgetSel);
|
||
|
||
watch(() => store.curRoleUid, getAppList);
|
||
|
||
watch(
|
||
() => store.getWidgetAppList,
|
||
val => {
|
||
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,
|
||
}
|
||
);
|
||
|
||
function onMenuChange(curDom: any, state: boolean) {
|
||
$(curDom).toggleClass("app-container__focus", state);
|
||
}
|
||
async function onMenuSel(item: any) {
|
||
if (item.id == 1) {
|
||
try {
|
||
const find = appList.value.find(x => x.id == item.appId);
|
||
await UserSettingApi.deleteUserDesktopAppById(find.id, find.appId);
|
||
Message.success("操作成功");
|
||
await getAppList();
|
||
} catch (error) {
|
||
Message.error("内部服务错误");
|
||
}
|
||
}
|
||
if (item.id == 2) {
|
||
let find = appList.value.find(x => x.appId == item.appId);
|
||
bschLinkPage({ url: find.url, type: LinkOpenTypeEnum.Open });
|
||
}
|
||
}
|
||
|
||
function onDragChange(e: any) {
|
||
var t: Element = e.target;
|
||
if (!!t && !includes(t.classList, "widget-header-drag")) dragObj.isDrag = false;
|
||
}
|
||
|
||
function onDragResizestop(opt: any, type: "drag" | "resize") {
|
||
const { left, top, width, height } = opt;
|
||
if (type === "drag") {
|
||
dragObj.isDrag = false;
|
||
if (left === dragPositionObj.xaxis && top === dragPositionObj.yaxis) return false;
|
||
} else {
|
||
}
|
||
let positionInfo: WidgetPositionResp = {
|
||
xaxis: left,
|
||
yaxis: top,
|
||
width,
|
||
height,
|
||
};
|
||
store.updateWidgetInfo(positionInfo);
|
||
debounceSaveUserWidget(positionInfo);
|
||
}
|
||
const debounceSaveUserWidget = debounce(saveUserWidget, 1000);
|
||
async function saveUserWidget(info: WidgetPositionResp) {
|
||
info.id = store.curUserSettingInfo?.id;
|
||
info.schId = store.getCurSchId;
|
||
await UserSettingApi.saveUserWidget(info);
|
||
}
|
||
|
||
const cancalDebounce = debounce(setDragWindowInfo, 150);
|
||
function setDragWindowInfo() {
|
||
dragObj.parentW = window.innerWidth;
|
||
dragObj.parentH = window.innerHeight;
|
||
}
|
||
|
||
function reloadDragWrap() {
|
||
if (!store.getWidgetDragObj.isWidgetShow) return;
|
||
isDragShow.value = false;
|
||
requestAnimationFrame(() => {
|
||
isDragShow.value = true;
|
||
});
|
||
}
|
||
|
||
async function onWidgetSelItemClick(appId: any) {
|
||
const curAppIdStrs = store.curUserSettingInfo.widgetAppIds;
|
||
const curAppIds = store.getWidgetAppIds;
|
||
let find = store.getWidgetSel.find(x => x.k == appId);
|
||
if (!find) return false;
|
||
let checkAppIds: number[] = [];
|
||
if (!!find.checked) {
|
||
if (curAppIdStrs == null) checkAppIds = difference(curAppIds, [find.k]);
|
||
else {
|
||
let tempArr = cloneDeep(curAppIds);
|
||
checkAppIds = remove(tempArr, (x: number) => x !== appId);
|
||
}
|
||
} else {
|
||
checkAppIds = [...curAppIds, find.k];
|
||
}
|
||
console.log(checkAppIds);
|
||
store.curUserSettingInfo.widgetAppIds = checkAppIds.join(",");
|
||
const id = store.curUserSettingInfo?.id;
|
||
await UserSettingApi.saveUserAppIds(id, checkAppIds.join(","));
|
||
console.log("后续操作");
|
||
}
|
||
function hideWidgetSel() {
|
||
dragObj.selVisible = false;
|
||
}
|
||
|
||
function showWidgetSelClick() {
|
||
dragObj.selVisible = true;
|
||
window.removeEventListener("click", hideWidgetSel);
|
||
requestAnimationFrame(() => {
|
||
window.addEventListener("click", hideWidgetSel, { once: true });
|
||
});
|
||
}
|
||
async function onClickWidgetHide() {
|
||
const id = store.curUserSettingInfo?.id;
|
||
if (!!store.curUserSettingInfo) store.curUserSettingInfo.isWidgetShow = false;
|
||
await UserSettingApi.toggleWidgetShow(id);
|
||
}
|
||
|
||
function onIframeLoad(id: number) {
|
||
const ele: any = document.getElementById("iframe_widget_" + id);
|
||
if (!ele) return false;
|
||
const msgObj = {
|
||
roleInfo: store.curRoleInfo,
|
||
id: id,
|
||
};
|
||
const msgStr = bschRoleEncode(msgObj);
|
||
ele.contentWindow.postMessage(msgStr, "*");
|
||
}
|
||
|
||
function iframeCallback(ev: MessageEvent<any>) {
|
||
const { id, height } = JSON.parse(ev.data);
|
||
let find = widgetAppList.value.find(x => x.id == id);
|
||
if (!find) return;
|
||
find.height = height;
|
||
requestAnimationFrame(() => {
|
||
find.loading = false;
|
||
});
|
||
//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);
|
||
isDesltopAppDefault.value = arr.filter(x => x.id == null).length === arr.length;
|
||
}
|
||
|
||
async function openLinkPage(item: any) {
|
||
let options: Parameters<typeof bschLinkPage>[0];
|
||
options = {
|
||
url: item.url as string,
|
||
type: item.linkType,
|
||
title: item.name,
|
||
};
|
||
|
||
if (item.type == 1 && $conf.ifameAppIdArr.includes(item.appId)) {
|
||
options.type = LinkOpenTypeEnum.IFrame;
|
||
options.isHeader = true;
|
||
}
|
||
|
||
if (item.linkType == LinkOpenTypeEnum.Modal) {
|
||
if (!!item.layout) {
|
||
options.modalWidth = JSON.parse(item.layout).width;
|
||
options.modalHeight = JSON.parse(item.layout).height;
|
||
}
|
||
}
|
||
bschLinkPage(options);
|
||
//bschLinkPage
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import url("@/assets/styles/componets/_widget.scss");
|
||
.used-app-wrap {
|
||
height: calc(100% - 88px);
|
||
}
|
||
.used-app-list {
|
||
margin-left: 20px;
|
||
display: flex;
|
||
flex-flow: column wrap;
|
||
justify-content: flex-start;
|
||
height: 100%;
|
||
align-items: stretch;
|
||
align-content: flex-start;
|
||
}
|
||
</style>
|
||
<style lang="scss">
|
||
.widget-wrap .vdr-stick-tm,
|
||
.vdr-stick-bm {
|
||
width: 380px !important;
|
||
left: 0 !important;
|
||
opacity: 0;
|
||
}
|
||
|
||
.vdr.active::before {
|
||
content: none;
|
||
}
|
||
|
||
/* 「+」添加小组件下拉:面板去雾做锐 —— 库默认的 0 1px 6px rgba(0,0,0,.2) 柔影在玻璃卡上糊出一圈灰晕,
|
||
改成 1px 实描边接管边界 + 投影收紧减淡;白底与文字色不动。transfer-class-name="wg-dd" 提供私有定位。 */
|
||
.ivu-select-dropdown.wg-dd {
|
||
padding: 4px 0;
|
||
background-color: #fff;
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 6px;
|
||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||
}
|
||
</style>
|