Files
UserhomeFront/src/views/index.vue
T
chenxing 2f4ee76b7b
build / build (push) Successful in 43s
style(小组件): 「+」添加小组件下拉去雾做锐 —— 1px 实描边接管边界、投影收紧减淡
- 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)
2026-10-14 09:49:22 +08:00

531 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>