Files
UserhomeFront/src/views/index.vue
T
chenxing 5d7b8526f4
build / build (push) Successful in 43s
fix(一周安排小组件): 面板关横向滚动(修外框 X 轴滚动条、日期条被裁)+ 色系 #5979ea、列表区固定 3 条、信息行全空落「-」、备注无则不渲染
- 色系:#2d8cf0 / #1d4ed8 整族换 #5979ea(hover #4864d8),覆盖今日方块、圆点、查看概览、展开、今日星期
- 列表区:268px(≈5 条) → calc(var(--wk-row) * 3) = 171px(正好 3 条 + 滚动),新增 --wk-row: 57px
- 条目等高 57px;信息行(地点 · 负责人 · 出席对象)恒渲染,三者全空落「-」,不再留白
- 备注:无备注整块不渲染;超 2 行才出「展开」且贴第二行末尾;remarkText 加 trim;换周重置 remarkOverflow
- 「查看概览 ›」→「查看概览」(去掉箭头字符)
- 修 X 轴滚动条根因(views/index.vue):widgetOptions 的 scrollPanel 关掉 scrollingX
  原 scrollingX=true 时 vuescroll 给 .__view 内联 width:fit-content,包含块被最长文本撑到 ≈425px
  ⇒ 面板 scrollWidth 561 > clientWidth 391,外框出 #333 横向条、日期条 7 列被裁成 5 列、长条目省略号失效
  同时 .content { min-width: 0 } 兜底(flex 项默认 min-width:auto 会让 nowrap 文本不收缩)
2026-10-16 09:50:39 +08:00

555 lines
21 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"
:overview-url="item.overviewUrl"
@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">
<!-- 用 -vec(纯矢量,颜色跟随 currentColor);widget-empty 是内嵌 PNG 的假 SVG,颜色烤死 -->
<SvgIcon name="widget-empty-vec" 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,
});
/**
* vuescroll 面板参数。
* ⚠️ scrollingX 必须显式 false:vuescroll 默认 true,此时 native-panel 会给 .__view 内联
* `width: fit-content; min-width: 100%`,`.widget-item` 的包含块就被「最长的 nowrap 文本」
* 撑宽(白卡 357px → 内容 ≈425px)⇒ 列表条目 text-overflow 永不触发(文字顶到卡边被硬裁)、
* 七日条按更宽容器等分(六/日被裁),面板 scrollWidth > clientWidth ⇒ 外框出横向滚动条。
* 关掉后 .__view 走 `width: 100%` 分支。面板内组件均不依赖横向滚动:
* 课表自带 .tt-scroll(纵向)、自定义片段是 iframe width:100%。
*/
const widgetOps = {
vuescroll: {},
scrollPanel: {
scrollingX: false,
},
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;
}
/* 「+」添加小组件下拉:与卡片同族(玻璃层)。下拉就地渲染在 .widget-wrap 内,可直接复用其玻璃令牌
(--wg-glass-*)保持同族;底色在同色相上加深一档,保证浮层层次与白字可读性。 */
.ivu-select-dropdown.wg-dd {
padding: 4px 0;
background-color: rgba(15, 23, 42, 0.72);
-webkit-backdrop-filter: blur(18px) saturate(140%);
backdrop-filter: blur(18px) saturate(140%);
border: 1px solid var(--wg-glass-border, rgba(255, 255, 255, 0.18));
border-radius: 6px;
box-shadow: var(--wg-glass-shadow, 0 10px 36px rgba(0, 0, 0, 0.3));
}
.ivu-select-dropdown.wg-dd .ivu-dropdown-item {
color: var(--wg-fg, rgba(255, 255, 255, 0.94));
text-shadow: var(--wg-fg-shadow, 0 1px 2px rgba(0, 0, 0, 0.35));
}
.ivu-select-dropdown.wg-dd .ivu-dropdown-item:hover {
background: rgba(255, 255, 255, 0.12);
}
</style>