feat: 接入 UserHome 前端业务代码(含真实 src/依赖,保留 projman 标准 CI)
deploy-userbase-front / build-and-deploy (push) Failing after 1m54s
build / build (push) Successful in 3m36s

This commit is contained in:
chenxing
2026-09-09 22:37:59 +08:00
parent a53ce07228
commit 4279f99be1
270 changed files with 12479 additions and 25715 deletions
+61
View File
@@ -0,0 +1,61 @@
<template>
<header class="header-container fix">
<a class="logout fl align-middle ml-24 mr-20" title="返回" @click="linkBack">
<SvgIcon name="back" class="fl" color="#fff" size="24" />
</a>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<img :src="userInfo.profilePicture" @click="showUserInfo" class="fl align-middle" />
<a class="name fl align-middle text-ellipsis" @click="showUserInfo" href="javascript:void(0);">
Hi
<span class="ml-4">{{ userInfo.name }}</span>
</a>
<!-- <div class="delimiter fl align-middle"></div> -->
<!-- <a href="javascript:;" class="logout fl align-middle" title="返回桌面" @click="linkBack">
<i class="iconfont icon-shouye"></i>
</a> -->
</div>
</header>
</template>
<script lang="ts" setup>
import { useDefaultStore } from "@/store";
import { computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import mittBus from "@/utils/mitt";
import { StateEnum } from "@/types";
import { useRouter } from "vue-router";
const store = useDefaultStore();
const router = useRouter();
const props = defineProps({
appName: {
type: String,
default: () => "",
},
});
const emitEvents = defineEmits(["on-back-click"]);
const userInfo = computed(() => store.userInfo);
function linkBack() {
emitEvents("on-back-click");
// router.push({ name: "index" });
// setTimeout(() => {
// mittBus.emit("updateState", StateEnum.Desktop);
// }, 10);
}
function showUserInfo() {
mittBus.emit("showUserInfoModal", true);
}
</script>
<style lang="scss">
@import url("@/assets/styles/componets/_app_header.scss");
</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<div class="draggable-wrapper" ref="containerRef" :style="{ left: compX + 'px', top: compY + 'px' }">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { useDraggable } from "@/utils/hook/useDraggable";
import { ref } from "vue";
interface Props {
x?: number;
y?: number;
el?: string;
}
const props = withDefaults(defineProps<Props>(), {
x: 100,
y: 120,
});
const containerRef = ref(null);
const { x: compX, y: compY, isDraggable } = useDraggable(containerRef, props.x, props.y);
</script>
<style lang="scss" scoped>
.draggable-wrapper {
position: fixed;
position: 20;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="app-bsch-iframe tc base-mask" v-if="isLoadding">
<img class="align-middle" style="width: 100px" :src="requireImg('page-loading.svg')" alt="" />
</div>
<div
:class="{
'app-bsch-iframe base-mask': true,
'app-bsch-iframe__header': isHeader,
}"
v-if="!!url"
v-show="!isLoadding"
>
<AppHeader :app-name="appName" v-if="!!isHeader" @on-back-click="onBackClick"></AppHeader>
<iframe id="app_bsch_iframe" :src="url" @load="onIframeLoad"></iframe>
</div>
</template>
<script lang="ts" setup>
import { useDefaultStore } from "@/store";
import { BaseLoadEnum, BschIframeType, IframePageParmas, StateEnum } from "@/types";
import { computed, ref, nextTick, onMounted, onUnmounted } from "vue";
import AppHeader from "./AppHeader.vue";
import mittBus from "@/utils/mitt";
import { bschRoleEncode, bschRoleParamsEncode } from "@/utils/bschRoleEncode";
import { requireImg } from "@/utils/hook/useImg";
import { useRouter, useRoute } from "vue-router";
const route = useRoute();
const router = useRouter();
const isLoadding = ref(true),
isHeader = ref(false),
appUrl = ref<string>(),
appName = ref<string>();
const store = useDefaultStore();
const url = computed(() => {
if (!!isHeader.value) {
if (!appUrl.value || !isValidUrl(appUrl.value)) return null;
let uri = new URL(appUrl.value);
if (!!isHeader.value) {
let { childId, roleId, schId } = store.curRoleInfo;
uri.searchParams.set("role", bschRoleParamsEncode(JSON.stringify({ childId, roleId, schId })));
}
return uri.href;
} else return appUrl.value;
});
const isValidUrl = (urlString: string) => {
try {
return Boolean(new URL(urlString));
} catch (e) {
return false;
}
};
async function openPage(params: IframePageParmas) {
isLoadding.value = true;
appUrl.value = "";
await nextTick();
isHeader.value = params.isHeader ?? false;
appUrl.value = params.url;
appName.value = params.appName;
setTimeout(() => {
isLoadding.value = false;
}, 500);
}
function onIframeLoad() {
const ele: any = document.getElementById("app_bsch_iframe");
if (!ele || !isHeader.value) return false;
const msgObj = {
roleInfo: store.curRoleInfo,
};
const msgStr = bschRoleEncode(msgObj);
ele.contentWindow.postMessage(msgStr, "*");
// isLoadding.value = true;
}
onMounted(function () {
window.addEventListener("message", iframeCallback);
});
onUnmounted(function () {
window.removeEventListener("message", iframeCallback);
});
function iframeCallback(ev: MessageEvent<any>) {
const { id, code } = JSON.parse(ev.data);
if (Number(code) == BaseLoadEnum.GoBack) {
router.push({ name: "index" });
setTimeout(() => {
mittBus.emit("updateState", StateEnum.Desktop);
}, 10);
} else if (Number(code) == BaseLoadEnum.UserInfo) {
showUserInfo();
}
}
function showUserInfo() {
mittBus.emit("showUserInfoModal", true);
}
function onBackClick() {
if (route.name === "AppDetails") router.push({ name: "index" });
setTimeout(() => {
mittBus.emit("updateState", StateEnum.Desktop);
}, 10);
}
defineExpose({ openPage });
</script>
<style></style>
-95
View File
@@ -1,95 +0,0 @@
<template>
<div class="improt-wrap">
<div class="form-item">
<label>案例下载:</label>
<a @click="downTempFile" href="javascript:;">点击下载模板.xls</a>
</div>
<div class="upload-panel">
<Upload type="drag" :on-success="onSuccess" :on-error="onError" :data="uploadData" :headers="uploadHeader"
:with-credentials="true" :action="uploadUrl"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel">
<div class="upload-box">
<Icon type="ios-paper" size="52" style="color: #6281eb"></Icon>
<h4>点击或将文件拖拽到这里上传</h4>
<div class="info">文件大小不超过50M 支持扩展名: xls,xlsx</div>
</div>
</Upload>
</div>
<Alert v-if="!!errorMsg" type="error" show-icon><span v-html="errorMsg"></span></Alert>
<Alert v-else-if="!!successMsg" type="success" show-icon>{{ successMsg }}</Alert>
<Alert v-else style="margin-bottom: 0" type="warning" show-icon>
请按照案例模板内容进行编辑,否则将无法导入。
</Alert>
</div>
</template>
<script>
import Utils from "@/utils";
export default {
props: {
tempFileUrl: String,
uploadUrl: String,
uploadData: Object,
},
data() {
return {
errorMsg: null,
successMsg: null,
};
},
computed: {
uploadHeader() {
const roleInfo = Utils.getCurrentRoleInfo();
roleInfo.schoolId = roleInfo.schId;
roleInfo.yschapirequest = 1;
// roleInfo.userType = 2;
return roleInfo;
},
},
methods: {
downTempFile() {
window.top.location.href = this.tempFileUrl;
},
onSuccess(res, file) {
if (res.code == 0) {
this.errorMsg = null;
this.successMsg = "操作成功!";
this.$emit("on-success");
} else {
this.errorMsg = res.msg;
}
},
onError(err, file) {
this.errorMsg = JSON.stringify(err);
console.log(err, file);
},
},
};
</script>
<style lang="scss" scoped>
.improt-wrap {
padding: 16px;
.form-item {
margin-bottom: 10px;
}
}
.upload-panel {
.upload-box {
background: #f7f8fc;
padding: 20px 0;
h4 {
color: #505c7e;
margin: 3px 0;
}
}
.info {
font-size: 13px;
color: #666;
}
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div ref="containerRef" class="context-container">
<slot></slot>
<Teleport to="body">
<Transition @beforeEnter="handleBeforeEnter" @enter="handleEnter" @afterEnter="handleAfterEnter">
<div class="context-menu" :style="{ left: x + 'px', top: y + 'px' }" v-if="visible">
<div @click="handleClick(item)" class="menu-item" v-for="item in menu" :key="item.label">
{{ item.label }}
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import { useContextMenu } from "@/utils/hook/useContextMenu";
import { ref, watch } from "vue";
interface Menu {
label: string;
[keys: string]: any;
}
const props = defineProps({
menu: {
type: Array<Menu>,
default: () => [],
},
});
const containerRef = ref(null);
const { x, y, visible } = useContextMenu(containerRef);
const emit = defineEmits(["select", "on-change"]);
watch(visible, val => emit("on-change", containerRef.value, val));
function handleClick(item: Menu) {
emit("select", item);
}
function handleBeforeEnter(el: any) {
el.style.height = 0;
}
function handleEnter(el: any) {
el.style.height = "auto";
const h = el.clientHeight;
el.style.height = 0;
requestAnimationFrame(() => {
el.style.height = h + "px";
el.style.transition = ".3s";
});
}
function handleAfterEnter(el: any) {
el.style.transition = "none";
}
</script>
<style lang="scss">
.context-menu {
background: #fff;
position: fixed;
padding: 6px;
border-radius: 4px;
overflow: hidden;
z-index: 20;
transition-timing-function: ease-in-out;
}
.menu-item {
line-height: 20px;
border-radius: 2px;
padding: 7px 16px;
color: #515a6e;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 400;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: #edf4ff;
}
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, computed, onMounted, withDefaults, nextTick } from "vue";
interface Props {
src: string;
color?: string;
hoverColor?: string;
size?: string;
}
const props = withDefaults(defineProps<Props>(), {
name: "",
color: "#000",
});
const refFetchSvg = ref<any>(null);
const isHover = ref(false),
svgData = ref(),
curViewBox = ref<string>("");
const svgStyle = computed(() => {
if (!!props.size) return { width: props.size + "px", height: props.size + "px" };
else return null;
});
const compColor = computed(() => {
if (!props.hoverColor) return props.color;
else {
return !!isHover.value ? props.hoverColor : props.color;
}
});
onMounted(async () => {
const svgResponse = await fetch(props.src);
const svgText = await svgResponse.text();
const divDom: HTMLDivElement = document.createElement("div");
divDom.innerHTML = svgText;
const svgDom = divDom.querySelector("svg");
if (svgDom == null) return;
svgDom.setAttribute("aria-hidden", "true");
svgDom.setAttribute("color", compColor.value);
svgDom.setAttribute("fill", compColor.value);
if (svgStyle.value != null) {
svgDom.setAttribute("width", svgStyle.value.width);
svgDom.setAttribute("height", svgStyle.value.height);
}
curViewBox.value = svgDom.getAttribute("viewBox") || "0 0 1024 1024";
svgData.value = svgDom?.innerHTML;
});
</script>
<template>
<svg
@mouseover="isHover = true"
@mouseout="isHover = false"
aria-hidden="true"
:color="compColor"
:fill="compColor"
:style="svgStyle"
:width="svgStyle?.width"
:height="svgStyle?.height"
:viewBox="curViewBox"
ref="refFetchSvg"
v-html="svgData"
></svg>
</template>
-294
View File
@@ -1,294 +0,0 @@
<template>
<div class="group-tree-search-panel">
<div v-if="searchVisible" class="search-bar mt-8 mb-8">
<Input v-model="searchKeyword" :placeholder="searchPlaceholder" suffix="ios-search" search clearable
class="search-input" @on-search="onSearch" @on-clear="onClear" />
<Button v-if="showCloseButton" icon="md-close" @click="closeSearch" />
</div>
<div class="tree-body">
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="autoClick" :treeData="treeData"
:setting="treeSetting" :isCheck="checkable" @on-tree-click="treeLink"
@on-tree-check="(node: any) => emit('on-tree-check', node)" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, useTemplateRef } from "vue";
import { cloneDeep } from "lodash";
import MyTree from '@/components/MyTree.vue'
const GROUP_TYPE = {
NORMAL_GROUP: 1,
SCHOOL_GROUP: 2,
GRADE_GROUP: 3,
CLASS_GROUP: 4,
AREA_GROUP: 5,
STAGE_GROUP: 6,
TEACHER_GROUP: 7,
STUDENT_GROUP: 8,
} as const
const DEFAULT_CACHE_KEY = 'bsch.group-tree.selected-node'
const props = withDefaults(defineProps<{
treeApi: () => Promise<any[]>;
autoClick?: boolean;
autoSelect?: boolean;
defaultSearchVisible?: boolean;
showCloseButton?: boolean;
searchPlaceholder?: string;
cacheKey?: string;
checkable?: boolean;
}>(), {
autoClick: false,
autoSelect: true,
defaultSearchVisible: true,
showCloseButton: false,
searchPlaceholder: '输入组名称搜索',
cacheKey: DEFAULT_CACHE_KEY,
checkable: false,
})
const emit = defineEmits<{
'on-change': [treeNode: any]
'on-tree-data': [tree: any[]]
'on-tree-check': [treeNode: any]
}>()
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
const refMyTree = useTemplateRef('refMyTree')
const treeLoading = ref(false)
const treeData = ref<any[]>([])
const searchKeyword = ref('')
const searchVisible = ref(props.defaultSearchVisible)
const treeSetting = ref({
view: { dblClickExpand: false, showIcon: false },
edit: { enable: false, showRemoveBtn: false, showRenameBtn: false, drag: false },
check: { enable: props.checkable, chkboxType: { Y: '', N: '' } },
callback: {},
})
function formatTree(list: any[], depth = 1): any[] {
return (list || []).map((node) => {
const children = node.children;
const hasChildren = Array.isArray(children) && children.length > 0;
const formatted: any = { ...node, isParent: hasChildren };
if (depth === 1) formatted.open = true;
if (hasChildren) {
formatted.children = formatTree(children, depth + 1);
} else {
delete formatted.children;
}
return formatted;
});
}
function findNodeById(list: any[], id?: number | null): any {
if (id == null) return null;
for (const node of list || []) {
if (Number(node.id) === Number(id)) return node;
const child = findNodeById(node.children || [], id);
if (child) return child;
}
return null;
}
function findFirstNodeByType(list: any[], type: number): any {
for (const node of list || []) {
if (Number(node.type) === type) return node;
const child = findFirstNodeByType(node.children || [], type);
if (child) return child;
}
return null;
}
function getCachedSelectedNodeId() {
try {
const raw = sessionStorage.getItem(props.cacheKey);
if (!raw) return null;
const cached = JSON.parse(raw);
const id = Number(cached?.id);
return Number.isFinite(id) ? id : null;
} catch {
return null;
}
}
function cacheSelectedNode(treeNode: any) {
if (!treeNode?.id) return;
sessionStorage.setItem(props.cacheKey, JSON.stringify({
id: Number(treeNode.id),
name: treeNode.name,
type: treeNode.type,
}));
}
function resolveInitialSelectId(list: any[], preferredId?: number | null) {
const preferred = findNodeById(list, preferredId);
if (preferred) return preferred.id;
const cached = findNodeById(list, getCachedSelectedNodeId());
if (cached) return cached.id;
const schoolNode = findFirstNodeByType(list, GROUP_TYPE.SCHOOL_GROUP);
if (schoolNode) return schoolNode.id;
if (props.autoClick && list.length) return list[0].id;
return list[0]?.id ?? null;
}
async function loadTree(preferredId?: number | null) {
treeLoading.value = true;
try {
const tree = await props.treeApi();
treeData.value = formatTree(tree || []);
emit('on-tree-data', cloneDeep(treeData.value));
} catch (e: any) {
console.warn(e?.message || '加载组织树失败');
} finally {
treeLoading.value = false;
}
if (!treeData.value.length) return;
await nextTick();
// 默认自动选中首个可用节点(触发 on-change);移动组等场景传 autoSelect=false 关闭,避免打开即误触"不能移动到自身"
if (props.autoSelect) {
const selectId = resolveInitialSelectId(treeData.value, preferredId);
if (selectId != null) {
refMyTree.value?.reloadTree(selectId);
}
}
}
async function refreshTree(selectId?: number) {
await loadTree(selectId);
}
function treeLink(treeNode: any) {
const copyNode = cloneDeep(treeNode)
copyNode.children = [];
curTreeNode.value = copyNode;
cacheSelectedNode(copyNode);
emit('on-change', copyNode)
}
function onSearch() {
refMyTree.value?.search(searchKeyword.value);
}
function onClear() {
searchKeyword.value = '';
refMyTree.value?.search('');
}
function openSearch() {
searchVisible.value = true;
}
function closeSearch() {
searchVisible.value = false;
onClear();
}
function reloadTree(id?: number) {
refMyTree.value?.reloadTree(id);
}
function selectNode(id: number, isClick = true) {
refMyTree.value?.selectNode(id, isClick);
}
function removeNode(id: number) {
refMyTree.value?.removeNode(id);
}
function renameNode(id: number, name: string) {
refMyTree.value?.renameNode(id, name);
}
function getNodeById(id: number) {
return refMyTree.value?.getNodeById(id);
}
function getCheckedNodes() {
return refMyTree.value?.getCheckedNodes() || [];
}
function checkNodeById(id: number, checked: boolean) {
refMyTree.value?.checkNodeById(id, checked);
}
function addNote(pid: number, id: number, name: string, isParent?: boolean, children?: any[]) {
refMyTree.value?.addNote(pid, id, name, isParent, children);
}
function openTree(open: boolean) {
refMyTree.value?.openTree(open);
}
function searchByKeyword(keyword: string) {
searchKeyword.value = keyword;
onSearch();
}
function clearByKeyword() {
searchKeyword.value = '';
onClear();
}
loadTree()
defineExpose({
openSearch,
closeSearch,
onSearch,
onClear,
searchByKeyword,
clearByKeyword,
loadTree,
refreshTree,
reloadTree,
selectNode,
removeNode,
renameNode,
getNodeById,
getCheckedNodes,
checkNodeById,
addNote,
openTree,
})
</script>
<style lang="scss" scoped>
.group-tree-search-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.search-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.search-input {
width: 100%;
}
.tree-body {
flex: 1;
min-height: 0;
}
.tree-body :deep(.ztree) {
height: 100%;
overflow-y: auto;
}
</style>
+602
View File
@@ -0,0 +1,602 @@
<template>
<header :class="headerClass">
<img v-if="!!logoUrl" :src="baseUrl + '/image/default-logo.png'" v-real-img="logoUrl" class="logo-box" alt="" />
<ul class="operate-container fix">
<!-- <li>
<Button @click="settingsMenu">test</Button>
</li> -->
<li v-if="isWidgetIconShow" :class="{ active: isWidgetShow }">
<div class="icon-box" v-wave @click="toggleWidgetShow">
<SvgIcon name="header-4" color="#333333" />
</div>
</li>
<li v-if="isSearchIconShow">
<Transition name="fade1" :duration="{ enter: 1000, leave: 0 }">
<div v-show="!isSearching" @click="isSearching = true" class="icon-box" v-wave>
<SvgIcon name="header-1" color="#333333" />
</div>
</Transition>
<Transition name="search" :duration="{ enter: 300, leave: 300 }">
<div v-show="isSearching" class="search-box">
<input ref="refSearchInput" v-model="searchAppText" type="text" placeholder="请输入应用名称搜索" />
<SvgIcon @click="isSearching = false" class="icon" name="header-1" size="20" color="#c4c4c4" />
</div>
</Transition>
</li>
<li>
<Poptip class="fl" popper-class="message-wrap reset-panel" :width="360" trigger="click"
placement="bottom-end" v-model="isMsgShow" :transfer="true">
<div class="icon-box" v-wave>
<SvgIcon name="header-2" color="#333333" />
</div>
<template #content>
<h3 class="title">查看消息</h3>
<vuescroll>
<ul class="msg-ul">
<li class="no-msg-box" v-if="userMsgList.length == 0">暂无消息</li>
<template v-else v-for="(item, index) in userMsgList">
<li class="fix">
<h4 :class="{
'text-ellipsis': true,
read: item.read,
}">
<span class="app-name-box">{{ item.name }}</span>
<span class="content" :title="item.msg"
@click="linkMessage(item.id, item.appPath, item.read)">
{{ item.msg }}
</span>
</h4>
<a href="javascript:;" @click="linkMessage(item.id, item.appPath, item.read)"
title="查看详情">
<SvgIcon name="arrow-right" color="#bbbbbb" />
</a>
</li>
</template>
</ul>
</vuescroll>
<div class="footer fix">
<a href="javascript:;" class="fl link-all" @click="showMsgList">查看全部</a>
<a href="javascript:;" class="fr read-all" v-if="userMsgList.length > 0"
@click="readMsgAll(1)">
全部已读
</a>
<!-- <a class="tzql_r" @click="clearMsg" href="javascript:;" title="清空消息"
><img src="image/msg-clear.png"
/></a> -->
</div>
</template>
</Poptip>
</li>
<li>
<Poptip class="fl" popper-class="roleinfo-wrap reset-panel" :width="240" trigger="click"
placement="bottom-end" v-model="isRoleInfoShow" :transfer="true">
<div class="icon-box" v-wave>
<SvgIcon name="header-3" color="#333333" />
</div>
<template #content>
<h3 class="title">
<SvgIcon name="switch-role" color="#272829" />
<span class="ml-8">切换身份</span>
</h3>
<vuescroll>
<ul class="roles-ul">
<template v-for="(item, index) in userRole">
<li :class="{ acitve: getIsCheckRoles(item) }" @click="changeRole(item)">
<SvgIcon v-if="getIsCheckRoles(item)" color="#45B972" name="role-checked" />
<SvgIcon v-else name="role-check" color="#dcdee0" />
<div class="user-wrap">
<div class="user-box fix">
<span v-if="item.roleId != 3" class="fl f16 mr-10">
{{ store.userInfo.name }}
</span>
<span v-else class="fl f16 mr-10">
{{ item.childName }}
</span>
<span v-if="!!item.roleName" class="role-info">
{{ item.roleName }}
</span>
</div>
<div class="schname-box">
{{ item.schName }}
</div>
</div>
</li>
</template>
</ul>
</vuescroll>
</template>
</Poptip>
</li>
<li>
<Poptip class="fl" popper-class="reset-panel" :width="220" trigger="click" placement="bottom-end"
v-model="isUserInfoShow" :transfer="true">
<div class="icon-box" v-wave>
<SvgIcon name="header-5" color="#333333" />
</div>
<template #content>
<ul class="userinfo-ul">
<!-- <li class="header border" @click="onUserinfoClickRoles">
<span class="name">{{ store.userInfo?.name }}</span>
<span v-if="!!store.curRoleInfo?.roleName" class="role-info mr-10">
{{ store.curRoleInfo?.roleName }}
</span>
<Icon :size="18" color="#333" type="ios-arrow-forward" />
</li> -->
<li class="border" @click="showUserInfo">
<img :src="requireImg('userinfo/icon1.png')" alt="" />
<span>个人信息</span>
</li>
<li v-if="!!store.getIsAdmin" @click="linkSysSetting">
<img :src="requireImg('userinfo/icon5.png')" alt="" />
<span>系统设置</span>
</li>
<li v-if="!!store.getIsAdmin" @click="linkLogoSetting">
<img :src="requireImg('userinfo/icon3.png')" alt="" />
<span>设置LOGO</span>
</li>
<li @click="settingsMenu" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon6.png')" alt="" />
<span>边栏设置</span>
</li>
<li @click="settingsStats" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon7.png')" alt="" />
<span>统计设置</span>
</li>
<!--<li @click="settingsTeacherCopilot" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon8.png')" alt="" />
<span>教师助手设置</span>
</li>-->
<li @click="settingsWallpaper" class="border">
<img :src="requireImg('userinfo/icon2.png')" alt="" />
<span>设置壁纸</span>
</li>
<li @click="logout">
<img :src="requireImg('userinfo/icon4.png')" alt="" />
<span>退出登陆</span>
</li>
</ul>
</template>
</Poptip>
</li>
</ul>
</header>
<Modal v-model="isMsgListShow" width="900" :mask="true" title="消息" class-name="msg-modal" :transfer="false">
<div class="fix mb-10">
<Button type="primary" @click="resetMsgPage" size="small" class="fl ml-10 width-10" ghost>刷新</Button>
<Button type="primary" @click="readMsgAll(2)" size="small" class="fl ml-10 width-10" ghost>全部已读</Button>
</div>
<TablePage :table-height="360" ref="refTablePage" page-type="small" :dataApi="tableApi" :pageParams="pageParams"
:columns="columns" :auto-load="false" />
<template #footer>
<Button type="primary" ghost @click="isMsgListShow = false">关闭</Button>
</template>
</Modal>
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
:transfer="false">
<vuescroll>
<div class="wallpaper-wrap">
<ul class="wallpaper-ul">
<template v-for="(item, index) in wallpaperList">
<li :class="(index + 1) % 6 == 0 ? '' : 'mr-16'" @click="onClickWallpaper(item.url)">
<div class="check-box">
<SvgIcon size="16" v-if="getIsWallpaper(item.url)" color="#45B972"
name="role-checked" />
<SvgIcon size="16" v-else name="role-check" color="#dcdee0" />
</div>
<label v-if="item.type == 0">系统</label>
<label v-if="item.type == 1">校</label>
<ContextMenu v-if="item.isEdit" @select="onWallpaperSelect" :menu="[
{
label: '删除',
id: item.id,
},
]">
<img :src="item.url" alt="" />
</ContextMenu>
<template v-else>
<img :src="item.url" alt="" />
</template>
<div class="mask-box">{{ index + 1 }}</div>
</li>
</template>
<li>
<Upload class="add-wrap tc" :on-success="wallpaperSuccess" :show-upload-list="false"
:action="$conf.serviceHost + '/api/wallpaperUpload'"
accept="image/jpeg, image/jpg, image/png" :with-credentials="true"
:headers="uploadHeaders">
<div class="add-box">
<SvgIcon name="upload-add" color="#939393" style="width: 24px; height: 24px" />
<p>点击上传图片</p>
</div>
</Upload>
</li>
</ul>
</div>
</vuescroll>
<template #footer>
<Button type="primary" ghost @click="isWallpaperShow = false">关闭</Button>
</template>
</Modal>
<Modal v-model="isLogoShow" class-name="logo-modal" width="720" :mask="false" title="设置LOGO" :transfer="false">
<vuescroll>
<div class="wallpaper-wrap">
<ul class="wallpaper-ul">
<template v-for="(item, index) in logoList">
<li :class="(index + 1) % 4 == 0 ? '' : 'mr-16'">
<label v-if="item.type == 0">系统</label>
<label v-else>校</label>
<ContextMenu v-if="item.isEdit" @select="onLogoSelect" :menu="[
{
label: '删除',
id: item.id,
},
]">
<img :src="item.url" alt="" />
</ContextMenu>
<template v-else>
<img :src="item.url" alt="" />
</template>
</li>
</template>
<li>
<Upload class="add-wrap tc" :on-success="logoSuccess" :show-upload-list="false"
:action="$conf.serviceHost + '/api/logoUpload'"
accept="image/jpeg, image/jpg, image/png, image/gif, image/webp"
:with-credentials="true" :headers="uploadHeaders">
<div class="add-box">
<SvgIcon name="upload-add" color="#939393" style="width: 24px; height: 24px" />
<p>点击上传图片</p>
</div>
</Upload>
</li>
</ul>
</div>
</vuescroll>
<template #footer>
<Button type="primary" ghost @click="isLogoShow = false">关闭</Button>
</template>
</Modal>
<MenuBarModal ref="refMenuBarModal" />
<StatsBarModal ref="refStatsBarModal" />
<TeacherCopilotModal ref="refTeacherCopilotModal" />
</template>
<script lang="ts" setup>
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
import vuescroll from "vuescroll";
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch } from "vue";
import { useDefaultStore } from "@/store";
import { useRoute, useRouter } from "vue-router";
import { requireImg } from "@/utils/hook/useImg";
import ContextMenu from "@/components/ContextMenu.vue";
import MenuBarModal from "@/components/MenuBarModal.vue";
import StatsBarModal from "@/components/StatsBarModal.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi";
import TablePage from "@/components/TablePage.vue";
import { IframePageParmas, RoleInfo } from "@/types";
import { isEqual, orderBy } from "lodash";
import { PageParams } from "@/types/Basics";
import UserSettingApi from "@/api/UserSettingApi";
import mittBus from "@/utils/mitt";
import MenuBarApi from "@/api/MenuBarApi";
import Utils from "@/utils";
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
const refTablePage: any = ref(null);
const { $conf } = useCurrentInstance();
const baseUrl = computed(() => $conf.fileHost);
const refSearchInput = ref<any>();
const refMenuBarModal = ref<any>();
const refStatsBarModal = ref<any>();
const refTeacherCopilotModal = ref<any>();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const logoUrl = computed(() => {
const schLogo = logoList.value.find(x => x.type == 1);
if (schLogo) return schLogo.url;
const sysLogo = logoList.value.find(x => x.type == 0);
return sysLogo ? sysLogo.url : "";
});
const isMsgShow = ref(false),
isMsgListShow = ref(false),
isUserInfoShow = ref(false),
isRoleInfoShow = ref(false),
isWallpaperShow = ref(false),
isLogoShow = ref(false),
isSearching = ref(false),
searchAppText = ref<string | null>(null),
wallpaperList = ref<any[]>([]),
logoList = ref<any[]>([]),
userMsgList = ref<any[]>([]);
const headerClass = computed(() => {
return { "default-header": true, "base-mask": route.name !== "index" };
});
const isWidgetIconShow = computed(() => {
return route.name === "index";
});
const isSearchIconShow = computed(() => {
return route.name === "apps";
});
const isWidgetShow = computed(() => {
return store.getWidgetDragObj.isWidgetShow;
});
const userRole = computed(() => store.userRoleList);
const toggleSearch: any = inject("toggleSearch");
const pageParams: PageParams = reactive({ page: 1, count: 10 });
const tableApi = (params: PageParams) => BaseApi.getMessagePageList(params);
const columns = ref<any[]>([
{
type: "index",
title: "序号",
width: 70,
align: "center",
},
{
title: "应用",
width: 150,
tooltip: true,
align: "center",
key: "name",
},
{
title: "消息内容",
tooltip: true,
key: "msg",
},
{
title: "状态",
width: 100,
render(h: any, params: any) {
return h("span", params.row.read ? "已读" : "未读");
},
},
{
title: "日期",
align: "center",
width: 200,
key: "date",
},
]);
onMounted(async () => {
setTimeout(() => {
reloadMsg();
// isWallpaperShow.value = true;
}, 200);
loadLogoList();
mittBus.on("searchReset", () => {
isSearching.value = false;
searchAppText.value = null;
});
});
onUnmounted(() => {
mittBus.off("searchReset");
});
watch(isSearching, val => {
if (val) {
setTimeout(() => refSearchInput.value?.focus(), 300);
} else {
// searchAppText.value = null;
}
});
watch(searchAppText, val => {
mittBus.emit("searchChange", val);
});
function linkMessage(msgId: number, path: string, isread: boolean) {
if (!isread) BaseApi.readMessage(msgId);
var index = userMsgList.value.findIndex(x => x.id == msgId);
if (index !== -1) userMsgList.value[index].read = true;
window.open(path);
}
function getIsCheckRoles(item: RoleInfo) {
return isEqual(item, store.curRoleInfo);
}
async function changeRole(role: RoleInfo) {
if (!!getIsCheckRoles(role)) return;
setTimeout(() => {
isRoleInfoShow.value = false;
}, 150);
store.setCurRoleInfo(role);
await BaseApi.savUserRole(role);
mittBus.emit("switchRole", role);
await getUserWallpaperList();
await loadLogoList();
}
function onUserinfoClickRoles() {
isUserInfoShow.value = false;
setTimeout(() => {
isRoleInfoShow.value = true;
}, 0);
}
async function readMsgAll(type: 1 | 2) {
await BaseApi.readMessageAll();
await reloadMsg();
if (type == 2) resetMsgPage();
}
async function reloadMsg() {
const result = await BaseApi.getMessageList();
userMsgList.value = result.list;
}
function resetMsgPage() {
refTablePage.value.reloadData();
}
function showMsgList() {
isMsgShow.value = false;
isMsgListShow.value = true;
refTablePage.value.reloadData();
}
async function wallpaperSuccess(response: any, file: any, fileListt: any) {
await getUserWallpaperList();
}
async function getUserWallpaperList() {
const list = await UserSettingApi.getUserWallpaperList(store.getCurSchId);
// list.forEach(element => {
// if (element.type == 1) element.url = $conf.serviceHost + element.url;
// });
wallpaperList.value = orderBy(list, ["type", "id"], ["asc", "asc"]);
}
async function settingsWallpaper() {
await getUserWallpaperList();
isUserInfoShow.value = false;
isWallpaperShow.value = true;
}
async function settingsMenu() {
isUserInfoShow.value = false;
refMenuBarModal.value.show();
}
async function settingsStats() {
isUserInfoShow.value = false;
refStatsBarModal.value.show();
}
async function settingsTeacherCopilot() {
isUserInfoShow.value = false;
refTeacherCopilotModal.value.show();
}
async function onClickWallpaper(url: string) {
await UserSettingApi.checkWallpaper(store.getCurSchId, url);
const userSetting = store.curUserSettingInfo;
if (!!userSetting) {
userSetting.wallpaperUrl = url;
store.setCurUserSettingsInfo(userSetting);
}
isWallpaperShow.value = false;
}
async function onWallpaperSelect(item: any) {
await UserSettingApi.deleteWallById(item.id);
await getUserWallpaperList();
}
async function loadLogoList() {
const list = await UserSettingApi.getLogoList();
logoList.value = list || [];
}
async function logoSuccess(response: any, file: any, fileListt: any) {
await loadLogoList();
}
async function onLogoSelect(item: any) {
await UserSettingApi.deleteLogoById(item.id);
await loadLogoList();
}
function getIsWallpaper(url: string) {
return url === store.getCurWallPaperUrl;
}
async function toggleWidgetShow() {
const id = store.curUserSettingInfo?.id;
if (!!store.curUserSettingInfo) store.curUserSettingInfo.isWidgetShow = !store.getWidgetDragObj.isWidgetShow;
await UserSettingApi.toggleWidgetShow(id);
}
function linkSysSetting() {
isUserInfoShow.value = false;
router.push("/syssetting");
}
async function linkLogoSetting() {
await loadLogoList();
isUserInfoShow.value = false;
isLogoShow.value = true;
}
function showSeachBox() { }
function showUserInfo() {
isUserInfoShow.value = false;
mittBus.emit("showUserInfoModal");
}
function logout() {
let url = window.location.href;
url = "/cas/logout?service=" + url;
url = $conf.ssoHost + url;
if (!!window.top) window.top.location.href = url;
else window.location.href = url;
}
</script>
<style lang="scss" scoped>
@import url("@/assets/styles/header.scss");
::v-deep .ivu-table td {
height: 32px;
}
</style>
<style lang="scss">
.reset-panel .ivu-poptip-body {
padding: 0;
}
.reset-panel .ivu-poptip-inner {
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.06);
}
.wallpaper-modal {
z-index: 11 !important;
}
.wallpaper-modal .ivu-modal-body {
padding: 0;
}
.logo-modal {
z-index: 11 !important;
}
.logo-modal .ivu-modal-body {
padding: 0;
}
.ivu-table-cell .operating-link {
display: inline-block;
color: #2d8cf0;
cursor: pointer;
padding-right: 10px;
&.disabled {
color: #aaa !important;
cursor: not-allowed;
}
}
.ivu-table:before {
content: none !important;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div :class="menuClass">
<ul class="menu-ul" v-if="menuVisible">
<li class="operate-item" @click="menuVisible = false" title="收起菜单">
<SvgIcon name="menu-operate" color="#ececec" />
</li>
<template v-for="(item, index) in menuList" :key="index">
<li
v-wave="myWave"
@click="linkPage(item, index)"
:class="{ active: index == activeIndex, 'first-bottom': index == menuFirstBottomIndex }"
>
<a :title="item.name">
<FetchSvg
size="24"
:src="$conf.serviceHost + item.icon"
:color="index == activeIndex ? '#fff' : '#ececec'"
/>
<span>{{ item.name }}</span>
</a>
</li>
</template>
<!-- <li class="fengche-item" v-wave="myWave">
<a href="javascript:void(0)">
<SvgIcon name="fengche" color="#ffffff" />
<span>随机壁纸</span>
</a>
</li> -->
<!-- <li class="fengche-item" v-wave="myWave">
<RouterLink :to="{ name: 'AppDetails', params: { type: 1, id: 500 } }">
<SvgIcon name="fengche" color="#ffffff" />
<span>随机壁纸</span>
</RouterLink>
</li> -->
</ul>
<div class="menu-arrow_box" title="展开菜单" v-else @click="menuVisible = true">
<SvgIcon name="menu-hide-arrow" color="white" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watchEffect, inject, nextTick } from "vue";
// import { BaseDict } from "@/types/Basics.ts";
import SvgIcon from "@/components/SvgIcon.vue";
import { useRoute, useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import { storeToRefs } from "pinia";
import { LinkOpenTypeEnum, StateEnum } from "@/types";
import FetchSvg from "@/components/FetchSvg.vue";
import mittBus from "@/utils/mitt";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { bschLinkPage, isTickeRoterByName } from "@/utils/bschLinkPage";
const { $conf } = useCurrentInstance();
const myWave = ref({
color: "#fff",
initialOpacity: 0.5,
duration: 0.45,
easing: "ease-in",
});
const route = useRoute(),
router = useRouter();
const store = useDefaultStore();
const { menuVisible } = storeToRefs(store);
const menuList = computed<any[]>(() => {
let topArr = store.curUserMenuList.filter(x => x.isBottom !== true).sort((a, b) => a.orderNum - b.orderNum);
let bottomArr = store.curUserMenuList.filter(x => x.isBottom === true).sort((a, b) => a.orderNum - b.orderNum);
return [...topArr, ...bottomArr];
});
//第一个悬浮底部的index
const menuFirstBottomIndex = computed<number>(() => menuList.value.findIndex(x => x.isBottom === true));
const activeIndex = ref(-1);
watchEffect(() => {
let findIndex = menuList.value.findIndex(x => x.url === route.path);
if (findIndex > -1) {
activeIndex.value = findIndex;
return;
}
if (isTickeRoterByName(route.name as string)) {
let menuId = route.params.menuId as string;
activeIndex.value = menuList.value.findIndex(x => x.id == menuId);
}
});
onMounted(() => {
setTimeout(() => {
$(".app-desktop").addClass("app-desktop__animation");
}, 0);
});
const menuClass = computed(() => {
return ["menu-wrap", menuVisible.value ? "menu-wrap__show" : "menu-wrap__hide"];
});
const isAdmin = computed(() => store.getIsAdmin);
async function linkPage(item: any, index: number) {
// if (!item.isNative && !item.isHeader) {
// bschLinkPage({ url: item.url, type: item.openType });
// return;
// }
if (!item.isNative && item.openType === LinkOpenTypeEnum.Open) {
window.open(item.url);
return;
}
mittBus.emit("updateState", StateEnum.Desktop);
mittBus.emit("reload");
if (!!item.isNative) {
router.push(item.url);
} else {
router.push({ name: "AppDetails", params: { menuId: item.id } });
}
}
</script>
<style lang="scss">
.menu-wrap {
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 70px;
z-index: 10;
// background: rgba(0, 0, 0, 0.4);
background-color: rgba(0, 0, 0, 0.3);
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(15px);
padding: 5px;
transition: left 0.2s ease-in-out, opacity 0.2s ease-in-out;
&__show {
left: 0;
}
&__hide {
left: -70px;
}
}
.menu-ul {
--textColor: #ececec;
display: flex;
flex-direction: column;
height: 100%;
li {
display: inline-block;
vertical-align: middle;
-webkit-transform: translateZ(0);
background-color: none;
transform: translateZ(0);
box-shadow: 0 0 1px rgba(0, 0, 0, 0);
backface-visibility: hidden;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
-webkit-transition-duration: 0.5s;
transition-duration: 0.5s;
-webkit-transition-property: color, background-color;
transition-property: color, background-color;
width: 60px;
height: 60px;
margin: 8px 0;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
a {
color: var(--textColor);
text-align: center;
font-size: 13px;
display: block;
line-height: normal;
}
svg {
display: inline-block;
width: 24px;
height: 24px;
}
span {
display: block;
}
}
li.operate-item {
cursor: pointer;
svg {
width: 42px;
height: 42px;
}
&:hover {
background: none;
}
}
li.fengche-item {
position: absolute;
bottom: 8px;
left: 5px;
}
li:hover {
background-color: rgba(255, 255, 255, 0.15);
}
li.active {
background-color: rgba(255, 255, 255, 0.3);
--textColor: #fff;
// color: #fff;
}
li.first-bottom {
margin-top: auto;
}
}
.menu-arrow_box {
position: relative;
left: 62px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 42px;
border-radius: 0px 8px 8px 0px;
background: rgba(0, 0, 0, 0.3);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(15px);
cursor: pointer;
svg {
width: 22px;
height: 22px;
display: inline-block;
}
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<Modal
v-model="isLightAppModalShow"
footer-hide
class-name="bsch-oepn-modal"
scrollable
:width="modalWidth"
:fullscreen="isFullscreen"
:title="modalTitle"
:transfer="false"
@on-cancel="handleModalCancel"
>
<template #header>
<div class="fix custom-title">
<div class="fl">{{ modalTitle }}</div>
<a class="fr" @click="isFullState = !isFullState">
<Icon v-if="!isFullState" :size="18" type="ios-expand" />
<Icon v-if="isFullState" :size="18" type="ios-contract" />
</a>
</div>
</template>
<div class="tc" :style="bschOpenIframeStyle" v-if="!!isLoading">
<img class="align-middle" style="width: 100px" :src="requireImg('page-loading.svg')" alt="" />
</div>
<iframe
v-if="isIframeShow"
v-show="!isLoading"
@load="onIframeLoad"
:style="bschOpenIframeStyle"
:src="iframeUrl"
frameborder="0"
></iframe>
</Modal>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import mittBus from "@/utils/mitt";
import { requireImg } from "@/utils/hook/useImg";
import { LightAppModal } from "@/types";
const isLightAppModalShow = ref(false),
isIframeShow = ref(false),
isLoading = ref(true),
modalWidth = ref(80),
modalHeight = ref(70),
modalTitle = ref(),
iframeUrl = ref();
const isFullState = ref(false);
const bschOpenIframeStyle = computed(() => {
if (isFullscreen.value)
return {
width: "100%",
height: "100%",
border: "none",
};
let height = ((document.documentElement.clientHeight - 52) * modalHeight.value) / 100;
return {
width: "100%",
height: height + "px",
border: "none",
};
});
const isFullscreen = computed(() => (modalWidth.value == 100 && modalHeight.value == 100) || !!isFullState.value);
onMounted(function () {
mittBus.on("showLightAppModal", (params: LightAppModal) => {
const { title, url, width, height } = params;
isIframeShow.value = true;
isLoading.value = true;
modalTitle.value = title;
iframeUrl.value = url;
modalWidth.value = width;
modalHeight.value = height;
console.log(title, url, width, height);
isLightAppModalShow.value = true;
setTimeout(() => {
isLoading.value = false;
}, 300);
});
});
onUnmounted(function () {
mittBus.off("showLightAppModal");
});
function onIframeLoad() {
isLoading.value = false;
}
function handleModalCancel() {
setTimeout(() => {
isFullState.value = false;
isIframeShow.value = false;
}, 300);
}
</script>
<style lang="scss">
.bsch-oepn-modal {
iframe {
width: 100%;
}
.ivu-modal-body {
padding: 0;
max-height: inherit;
}
}
</style>
<style lang="scss" scoped>
.custom-title {
width: calc(100% - 30px);
line-height: 20px;
color: #17233d;
position: relative;
a {
color: #999 !important;
position: absolute;
top: 5px;
right: 0;
&:hover {
color: #17233d !important;
}
}
}
</style>
+337
View File
@@ -0,0 +1,337 @@
<template>
<Modal
v-model="isTableModalShow"
class-name="menuwapper-modal"
width="1160"
:mask="true"
title="边栏设置"
:transfer="false"
>
<div class="fix mb-10">
<Button type="primary" size="small" class="fl ml-10 width-10" ghost @click="onEditFormShow()">
新建边栏
</Button>
</div>
<Table
:columns="menuBarColumns"
:height="360"
:loading="tableLoading"
:data="tableList"
class="link-table"
no-data-text="暂无数据"
>
<template #icon="{ row }">
<img class="icon-box" v-if="!!row.icon" :src="$conf.serviceHost + row.icon" alt="" />
</template>
<template #isShow="{ row }">
<Switch :model-value="row.isShow" true-color="#5979ea" @on-change="onMenuBarShowChange(row.id)" />
</template>
<template #roleIds="{ row }">
<span>{{ row.roleIds.map((item: number) => RoleTypeDict.find(x => x.k == item)?.v).join(",") }}</span>
</template>
<!-- <template #orderNum="{ row }">
<InputNumber :model-value="row.orderNum" :min="1" />
</template> -->
</Table>
<template #footer>
<Button type="primary" ghost @click="isTableModalShow = false">关闭</Button>
</template>
</Modal>
<Modal
v-model="isEditModalShow"
scrollable
width="550"
:title="(!formData.id ? '添加' : '编辑') + '边栏'"
:transfer="true"
>
<Form ref="refEditForm" class="mr-30" :model="formData" :rules="ruleValidate" :label-width="110">
<Form-Item label="权限角色:" class="fix" prop="roleIds">
<Checkbox
class="fl"
:indeterminate="indeterminate"
:model-value="compCheckAll"
@click.prevent="handleCheckAll"
>
全部
</Checkbox>
<CheckboxGroup v-model="formData.roleIds">
<Checkbox v-for="item in RoleTypeDict" :label="item.k">{{ item.v }}</Checkbox>
</CheckboxGroup>
</Form-Item>
<Form-Item label="名称:" prop="name">
<Input max="4" v-model="formData.name" maxlength="30" placeholder="请输入名称" />
</Form-Item>
<Form-Item label="网址:" prop="url">
<Input v-model="formData.url" placeholder="请输入链接" />
</Form-Item>
<Form-Item label="图标:" prop="icon">
<Upload
:on-success="uploadIconSuccess"
:show-upload-list="false"
:action="$conf.serviceHost + '/api/iconUpload'"
accept=".svg"
:with-credentials="true"
:headers="uploadHeaders"
>
<div v-if="!formData.icon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon
@click.stop="formData.icon = null"
class="cloase-btn"
name="widget-close"
size="18"
color="#fff"
/>
<img :src="$conf.serviceHost + formData.icon" alt="" />
</div>
</Upload>
</Form-Item>
<Form-Item label="排序号:" prop="orderNum">
<InputNumber style="width: 120px" v-model="formData.orderNum" :min="1" placeholder="请输入排序号" />
</Form-Item>
<Form-Item label="是否悬浮底部:" prop="isNative">
<Switch v-model="formData.isBottom" true-color="#5979ea" />
</Form-Item>
<Form-Item label="是否系统页面:" prop="isNative">
<Switch v-model="formData.isNative" true-color="#5979ea" />
</Form-Item>
<Transition name="fade" :duration="300">
<div class="ovh" style="height: 34px">
<Form-Item v-if="!formData.isNative" label="打开方式:" prop="linkOpenType">
<RadioGroup v-model="formData.linkOpenType" type="button" button-style="solid">
<Radio :label="item.k" v-for="item in linkOpenTypeDict">{{ item.v }}</Radio>
</RadioGroup>
</Form-Item>
</div>
</Transition>
<Transition name="fade" :duration="300">
<div class="ovh mt-24" style="height: 35px">
<Form-Item
v-if="!formData.isNative && formData.linkOpenType === LinkOpenTypeEnum.IFrame"
label="是否显示头部:"
prop="isHeader"
>
<Switch v-model="formData.isHeader" true-color="#5979ea" />
</Form-Item>
</div>
</Transition>
</Form>
<template #footer>
<Button type="primary" :loading="editLoading" @click="onEditSave">保存</Button>
<Button @click="isEditModalShow = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import MenuBarApi from "@/api/MenuBarApi";
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeEnum, RoleTypeDict } from "@/types";
import Utils from "@/utils";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { Message } from "view-ui-plus";
import { ref, reactive, computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import { BaseDict } from "@/types/Basics";
import { find } from "lodash";
interface FormData {
id: string | null;
roleIds: number[];
name: string | null;
url: string | null;
linkOpenType: number;
icon: string | null;
orderNum: number | null;
isBottom: boolean | null;
isNative: boolean;
isShow: boolean;
isEdit: boolean;
isHeader: boolean | null;
}
// 初始化 formData 的默认值
const defaultFormData: FormData = {
id: null,
roleIds: [], // 添加 roleIds 属性
name: "",
url: "",
linkOpenType: 0,
icon: "",
orderNum: null,
isBottom: false,
isNative: false,
isHeader: false,
isShow: true,
isEdit: true,
};
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const refEditForm = ref<any>(null);
const isTableModalShow = ref(false),
isEditModalShow = ref(false),
tableLoading = ref(false),
editLoading = ref(false),
tableList = ref<any[]>([]),
formData = reactive(defaultFormData);
const ruleValidate = ref({
roleIds: { required: true, type: "array", message: "请选择权限角色", trigger: "change,submit" },
name: { required: true, message: "请输入名称", trigger: "blur" },
url: { required: true, message: "请输入网址", trigger: "blur" },
icon: { required: true, message: "请上传图标", trigger: "change,submit" },
});
const linkOpenTypeDict = computed(() => LinkOpenTypeDict.filter(x => x.k != LinkOpenTypeEnum.Modal));
const compCheckAll = computed(() => formData.roleIds.length == RoleTypeDict.length && RoleTypeDict.length > 0);
const indeterminate = computed(() => formData.roleIds.length > 0 && formData.roleIds.length < RoleTypeDict.length);
const menuBarColumns = ref([
{
title: "名称",
key: "name",
},
{
title: "图标",
slot: "icon",
},
{
title: "角色",
width: 150,
slot: "roleIds",
},
{
title: "地址",
key: "url",
width: 150,
tooltip: true,
},
{
title: "打开方式",
key: "linkType",
},
{
title: "系统页面",
key: "isNative",
width: 110,
align: "center",
render(h: any, params: any) {
return h("span", params.row.isNative ? "是" : "否");
},
},
{
title: "是否显示",
width: 100,
align: "center",
slot: "isShow",
},
{
title: "排序号",
key: "orderNum",
align: "center",
},
{
title: "操作",
width: 120,
align: "center",
render: (h: any, params: any) => {
const disabled = !params.row.isEdit,
id = params.row.id;
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => onEditFormShow(id), disabled),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteMenu(id), disabled),
];
},
},
]);
async function show() {
isTableModalShow.value = true;
await getMenuBarList();
console.log("show");
}
async function getMenuBarList() {
tableLoading.value = true;
const list = await MenuBarApi.getMenuBarSchList();
list.forEach(element => {
element.linkType = find(linkOpenTypeDict.value, { k: element.linkOpenType })?.v;
});
tableList.value = list;
tableLoading.value = false;
}
function handleCheckAll() {
formData.roleIds = !compCheckAll.value ? RoleTypeDict.map(item => item.k) : [];
}
function uploadIconSuccess(response: any, file: any, fileListt: any) {
if (response.code == 0) formData.icon = response.data.url;
else Message.error("上传错误!");
// console.log(response, file, fileListt);
}
async function onEditFormShow(id?: number) {
await refEditForm.value?.resetFields();
isEditModalShow.value = false;
if (!id) {
formData.id = null;
formData.linkOpenType = LinkOpenTypeEnum.Open;
} else {
let find = tableList.value.find(x => x.id == id);
Object.assign(formData, find);
}
isEditModalShow.value = true;
}
async function onDeleteMenu(id: number) {
await MyConfirm({
title: "删除",
msg: "确定删除该边栏吗?",
info: "删除后无法恢复!",
async onOk() {
await MenuBarApi.deleteMenuBar(id);
},
});
await getMenuBarList();
}
async function onEditSave() {
let validate: boolean = await refEditForm.value?.validate();
if (!validate) return false;
editLoading.value = true;
try {
await MenuBarApi.saveMenuBar(formData);
Message.success("操作成功!");
await getMenuBarList();
isEditModalShow.value = false;
} catch (error) {
Message.success("操作失败!");
} finally {
editLoading.value = false;
}
}
function onMenuBarShowChange(id: number) {
MenuBarApi.toggleMenuBarShow(id);
}
defineExpose({
show,
});
</script>
<style scoped>
.icon-box {
width: 20px;
height: 20px;
}
</style>
-178
View File
@@ -1,178 +0,0 @@
<template>
<div class="module-authz-tree">
<div v-for="node in visibleNodes" :key="node.id" class="tree-node"
:style="{ paddingLeft: `${(node.level || 1) * 18 - 18}px` }"
:class="{ 'is-leaf': !node.children?.length, 'is-expanded': node.expand !== false }">
<div class="node-main">
<span v-if="node.children?.length" class="expand-icon" @click.stop="toggleExpand(node)">
<Icon :type="node.expand === false ? 'ios-add' : 'ios-remove'" />
</span>
<span v-else class="expand-placeholder"></span>
<Checkbox :model-value="!!node.checked"
:disabled="loadingId === node.id && loadingType === 'check'"
@on-change="(checked: boolean) => onCheckChange(node, checked)">
<span class="node-name" :title="node.name">{{ node.name }}</span>
</Checkbox>
</div>
<div class="node-inherit">
<Checkbox :model-value="!!node.inheritFlag"
:disabled="!node.checked || (loadingId === node.id && loadingType === 'inherit')"
@on-change="(checked: boolean) => onInheritChange(node, checked)">
</Checkbox>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive } from "vue";
export interface ModuleAuthzNode {
id: number;
name: string;
level?: number;
checked?: boolean;
inheritFlag?: boolean;
expand?: boolean;
children?: ModuleAuthzNode[];
}
const props = defineProps<{
nodes: ModuleAuthzNode[];
appId: number;
groupId: number;
loadingId?: number | null;
loadingType?: "check" | "inherit" | null;
}>();
const emit = defineEmits<{
(e: "module-change", payload: {
appId: number;
groupId: number;
moduleId: number;
checked: boolean;
inheritFlag: boolean;
type: "check" | "inherit";
}): void;
}>();
const expandMap = reactive<Record<number, boolean>>({});
function buildLocalNodes(nodes: ModuleAuthzNode[]): ModuleAuthzNode[] {
return (nodes || []).map((n) => ({
...n,
expand: expandMap[n.id] ?? true,
children: buildLocalNodes(n.children || []),
}));
}
const localNodes = computed<ModuleAuthzNode[]>(() => buildLocalNodes(props.nodes));
const visibleNodes = computed<ModuleAuthzNode[]>(() => {
const result: ModuleAuthzNode[] = [];
const walk = (list: ModuleAuthzNode[]) => {
for (const node of list) {
result.push(node);
if (node.children?.length && node.expand !== false) {
walk(node.children);
}
}
};
walk(localNodes.value);
return result;
});
function toggleExpand(node: ModuleAuthzNode) {
expandMap[node.id] = node.expand === false;
}
function onCheckChange(node: ModuleAuthzNode, checked: boolean) {
node.checked = checked;
if (!checked) {
node.inheritFlag = false;
}
emit("module-change", {
appId: props.appId,
groupId: props.groupId,
moduleId: node.id,
checked,
inheritFlag: !!node.inheritFlag,
type: "check",
});
}
function onInheritChange(node: ModuleAuthzNode, checked: boolean) {
node.inheritFlag = checked;
emit("module-change", {
appId: props.appId,
groupId: props.groupId,
moduleId: node.id,
checked: !!node.checked,
inheritFlag: checked,
type: "inherit",
});
}
</script>
<style lang="scss" scoped>
.module-authz-tree {
user-select: none;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
min-height: 32px;
border-bottom: 1px dashed #f0f0f0;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f7f7f7;
}
}
.node-main {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: 4px;
}
.expand-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #999;
font-size: 16px;
flex-shrink: 0;
&:hover {
color: #2d8cf0;
}
}
.expand-placeholder {
width: 18px;
flex-shrink: 0;
}
.node-name {
font-size: 13px;
color: #333;
}
.node-inherit {
width: 80px;
text-align: center;
flex-shrink: 0;
}
</style>
-221
View File
@@ -1,221 +0,0 @@
<template>
<div>
<div :id="domId" :ref="domId" class="ztree"></div>
</div>
</template>
<script>
export default {
props: {
treeData: {
type: Object,
},
setting: {
type: Object,
default: {},
},
autoClick: {
type: Boolean,
default: true,
},
isCheck: {
typeof: Boolean,
default: false,
},
},
data() {
return {
domId: "",
treeSetting: _.cloneDeep(this.setting),
treeObj: null,
highlightNodes: [],
};
},
created() {
this.$watch("treeData", val => {
if (val) {
this.$nextTick(() => {
this.initTree(this.treeData, this.domId);
});
}
});
},
mounted() {
this.domId = this.uuid();
if (this.treeData) {
this.$nextTick(() => {
this.initTree(this.treeData, this.domId);
});
}
},
methods: {
uuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
},
reloadTree(nodeId) {
this.initTree(this.treeData, this.domId);
if (nodeId != null) {
this.$nextTick(() => {
this.selectNode(nodeId, true); // 第二个参数 true 会 emit on-tree-click
});
}
},
initTree(_data, _domId) {
const _this = this;
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
// console.log(event, treeId, treeNode, clickFlag);
if (_this.isCheck) {
var treeObj = $.fn.zTree.getZTreeObj(treeId);
// 第4参 callbackFlag=true:点击文字切换勾选时同步触发 onCheck,与点击勾选框行为一致
treeObj.checkNode(treeNode, !treeNode.checked, true, true);
}
_this.$emit("on-tree-click", treeNode);
};
_this.treeSetting.callback.beforeDrop = (treeId, treeNodes, targetNode, moveType) => {
if (moveType == "inner") {
// 移除限制,允许叶子节点接受子节点
} else {
if (targetNode.pid === 0) return false;
}
return true;
};
_this.treeSetting.callback.beforeDrag = (treeId, treeNodes) => {
return treeNodes[0].drag !== false;
};
_this.treeSetting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
return !(treeNode.click === false);
};
_this.treeSetting.callback.onDrop = (event, treeId, treeNodes, targetNode, moveType) => {
_this.$emit("on-tree-drag", { treeNodes, targetNode, moveType });
};
_this.treeSetting.callback.onCheck = (event, treeId, treeNode) => {
_this.$emit("on-tree-check", treeNode);
};
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
if (_this.autoClick) {
if (Array.isArray(this.treeData)) {
let id = this.treeData[0].id;
_this.selectNode(id);
} else {
let id = this.treeData.id;
_this.selectNode(id);
}
}
},
selectNode(id, isClick = true) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
if (node == null) return false;
treeObj.selectNode(node);
treeObj.expandNode(node, true, false, false);
treeObj.selectNode(node);
if (isClick) this.$emit("on-tree-click", node);
},
openTree(open) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
treeObj.expandAll(open);
},
removeNode(id) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
var pnode = node.getParentNode();
treeObj.removeNode(node);
pnode.isParent = true;
treeObj.updateNode(pnode);
this.selectNode(pnode.id);
},
getNodeById(id) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
return node;
},
getCheckedNodes() {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
if (!treeObj) return [];
return treeObj.getCheckedNodes(true);
},
// 仅更新节点勾选状态,不级联、不触发 onCheck 回调
checkNodeById(id, checked) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
if (!treeObj) return;
var node = treeObj.getNodesByFilter(node => node.id == id, true);
if (node) treeObj.checkNode(node, !!checked, false);
},
renameNode(id, name) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
node.name = name;
treeObj.updateNode(node);
},
addNote(pid, id, name, isParent, children) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
var newNode = { pid, id, name, isParent };
if (children && children.length) newNode.children = children;
treeObj.addNodes(pnode, -1, newNode);
},
setHighlight(node, on = true) {
const a = $('#' + node.tId + '_a');
if (on) a.addClass('ztree-search-highlight');
else a.removeClass('ztree-search-highlight');
},
clearHighlight() {
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
this.highlightNodes = [];
},
search(keyword) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
if (!treeObj) return;
this.clearHighlight();
const kw = (keyword || '').trim().toLowerCase();
if (!kw) return;
const matchNodes = treeObj.getNodesByFilter(
(node) => node.name && node.name.toLowerCase().includes(kw),
false
) || [];
if (!matchNodes.length) {
if (this.$Message && this.$Message.info) this.$Message.info('未搜索到匹配组');
return;
}
matchNodes.forEach(n => {
let p = n;
while (p) {
treeObj.expandNode(p, true, false, false);
p = p.getParentNode();
}
});
this.highlightNodes = matchNodes;
matchNodes.forEach(n => this.setHighlight(n, true));
this.selectNode(matchNodes[0].id, true);
},
},
};
</script>
<style>
.ztree-search-highlight {
background-color: #ff6600 !important;
}
.ztree li a.curSelectedNode {
background-color: #ffe4bb !important;
}
</style>
-234
View File
@@ -1,234 +0,0 @@
<template>
<div style="height: 100%;">
<div class="tree-search-bar">
<Input v-model="keyword" placeholder="输入学校,回车查找" suffix="ios-search" clearable @on-enter="onSearch"
@on-clear="onSearch" style="width:100%;margin-bottom: 10px;" />
</div>
<div :id="domId" :ref="domId" class="ztree"></div>
</div>
</template>
<script>
export default {
props: {
treeData: {
type: Object,
},
setting: {
type: Object,
default: {
view: {
dblClickExpand: false,
showIcon: false,
},
edit: {
enable: true,
showRemoveBtn: false,
showRenameBtn: false,
drag: false,
},
check: {
enable: false,
},
callback: {},
},
},
autoClick: {
type: Boolean,
default: true,
},
isCheck: {
typeof: Boolean,
default: false,
},
},
data() {
return {
domId: "",
treeSetting: _.cloneDeep(this.setting),
treeObj: null,
keyword: '',
highlightNodes: []
};
},
created() {
this.$watch("treeData", val => {
console.log(val);
if (val) {
console.log(val);
this.$nextTick(() => {
this.initTree(this.treeData, this.domId);
});
}
});
},
mounted() {
this.domId = this.uuid();
},
methods: {
uuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
},
reloadTree() {
this.treeData.open = true;
this.initTree(this.treeData, this.domId);
},
initTree(_data, _domId) {
const _this = this;
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
// console.log(event, treeId, treeNode, clickFlag);
if (_this.isCheck) {
var treeObj = $.fn.zTree.getZTreeObj(treeId);
treeObj.checkNode(treeNode, !treeNode.checked, true);
}
_this.$emit("on-tree-click", treeNode);
};
_this.treeSetting.callback.beforeDrop = (treeId, treeNodes, targetNode, moveType) => {
if (moveType == "inner") {
if (!targetNode.isParent) return false;
} else {
if (targetNode.pid === 0) return false;
}
};
_this.treeSetting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
return !(treeNode.click === false);
};
_this.treeSetting.callback.onDrop = (event, treeId, treeNodes, targetNode, moveType) => {
_this.$emit("on-tree-drag", { treeNodes, targetNode, moveType });
};
_this.treeSetting.callback.onCheck = (event, treeId, treeNode) => {
_this.$emit("on-tree-check", treeNode);
};
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
if (_this.autoClick) {
if (Array.isArray(this.treeData)) {
let id = this.treeData[0].id;
_this.selectNode(id);
} else {
let id = this.treeData.id;
_this.selectNode(id);
}
}
},
selectNode(id, isClick = true) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
if (node == null) return false;
treeObj.selectNode(node);
treeObj.expandNode(node, true, false, false);
treeObj.selectNode(node);
if (isClick) this.$emit("on-tree-click", node);
},
openTree(open) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
treeObj.expandAll(open);
},
removeNode(id) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
var pnode = node.getParentNode();
treeObj.removeNode(node);
pnode.isParent = true;
treeObj.updateNode(pnode);
this.selectNode(pnode.id);
},
getNodeById(id) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
return node;
},
renameNode(id, name) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var node = treeObj.getNodesByFilter(node => node.id == id, true);
node.name = name;
treeObj.updateNode(node);
},
addNote(pid, id, name, isParent) {
const _domId = this.domId;
var treeObj = $.fn.zTree.getZTreeObj(_domId);
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
var newNode = { pid, id, name, isParent };
treeObj.addNodes(pnode, -1, newNode);
},
onSearch() {
this.clearHighlight();
if (!this.keyword) return;
this.doSearch(this.keyword);
},
doSearch(key) {
const treeObj = this.treeObj;
// 1. 模糊匹配
const matchNodes = treeObj.getNodesByFilter(node =>
node.type == 2 &&
node.name && node.name.toLowerCase().includes(key.toLowerCase())
);
if (!matchNodes.length) {
this.$Message.info('未搜索到匹配学校')
return;
}
// 2. 展开所有命中节点的父节点
matchNodes.forEach(n => {
let p = n.getParentNode();
while (p) {
treeObj.expandNode(p, true, false, false);
p = p.getParentNode();
}
});
// 3. 高亮
this.highlightNodes = matchNodes;
matchNodes.forEach(n => this.setHighlight(n, true));
// const box = document.getElementById('treeBox');
// const nodeEl = document.getElementById(matchNodes[0].tId);
// box.scrollTop = nodeEl.offsetTop - box.clientHeight / 2; // 居中
if (matchNodes.length) {
const treeObj = this.treeObj;
console.log(matchNodes[0]);
this.selectNode(matchNodes[0].id);
// treeObj.selectNode(matchNodes[0]); // 先选中,会自动滚入可视区
//this.$nextTick(() => treeObj.cancelSelectedNode()); // 取消选中,保持纯高亮
}
},
setHighlight(node, on = true) {
const a = $('#' + node.tId + '_a');
if (on) a.addClass('ztree-search-highlight');
else a.removeClass('ztree-search-highlight');
},
clearHighlight() {
this.highlightNodes.forEach(n => this.setHighlight(n, false));
this.highlightNodes = [];
}
},
};
</script>
<style>
.ztree {
height: calc(100% - 60px);
overflow-y: auto;
}
.ztree-search-highlight {
background-color: #ff6600 !important;
}
.ztree li a.curSelectedNode {
background-color: #ffe4bb !important;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div :style="searchStyle" @click="toggleSearch()">
<div :class="searchClass">
<div class="app-search-input" @click="e => e.stopPropagation()">
<Input
ref="refInput"
v-model="searchName"
prefix="ios-search"
placeholder="输入应用名称搜索"
type="text"
/>
</div>
<vuescroll>
<div class="app-search-list">
<div v-for="(item, index) in appList" class="app-container">
<img :src="$utils.appIconPath(item.icon)" />
<div class="app-name">{{ item.name }}</div>
</div>
</div>
</vuescroll>
</div>
</div>
</template>
<script lang="ts" setup>
import { useDefaultStore } from "@/store";
import { ref, inject, computed, onMounted, watch } from "vue";
import vuescroll from "vuescroll";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
const { $utils } = useCurrentInstance();
const refInput = ref();
const searchName = ref();
const store = useDefaultStore();
const searchClass = ref<Array<string>>(["app-search-warp"]);
const isSearchShow: any = inject("isSearchShow");
const toggleSearch: any = inject("toggleSearch");
const searchStyle = computed(() => `background-image: url(${store.getCurWallPaperUrl})`);
const appList = computed(() => {
const allAppList = store.getFlattenAppList;
if (!searchName.value) return allAppList;
return allAppList.filter(x => x.name.indexOf(searchName.value) !== -1);
});
onMounted(async () => {});
watch(isSearchShow, async val => {
if (val) {
setTimeout(() => {
searchClass.value.push("app-search-warp__animate");
refInput.value.focus();
}, 50);
} else {
searchClass.value = ["app-search-warp"];
searchName.value = null;
}
});
</script>
<style lang="scss" scoped>
.app-search-warp {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
inset: 0;
outline: none;
transition: transform 0.2s ease-in;
transform: scale(1.05);
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
padding-bottom: 50px;
z-index: 2000;
&__animate {
opacity: 1;
transform: scale(1);
}
}
.app-search-input {
width: 300px;
margin: 50px auto 20px;
padding: 10px;
}
::v-deep .app-search-input .ivu-input {
background-color: #28323c;
border-color: #58626c;
height: 36px;
color: #c5c8ce;
&::placeholder {
color: #727c86;
}
&:hover {
border-color: #8a949e;
}
&:focus {
border-color: #2396fa;
}
}
.app-search-list {
width: 860px;
margin: 0 auto;
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
.app-container {
margin: 20px 25px;
}
}
</style>
+301
View File
@@ -0,0 +1,301 @@
<template>
<Modal
v-model="isTableModalShow"
class-name="menuwapper-modal"
width="1160"
:mask="true"
title="统计模块设置"
:transfer="false"
>
<div class="fix mb-10">
<Button type="primary" size="small" class="fl ml-10 width-10" ghost @click="onEditFormShow()">
新建统计模块
</Button>
</div>
<Table
:columns="menuBarColumns"
:height="360"
:loading="tableLoading"
:data="tableList"
class="link-table"
no-data-text="暂无数据"
>
<template #icon="{ row }">
<img class="icon-box" v-if="!!row.icon" :src="$conf.serviceHost + row.icon" alt="" />
</template>
<template #isShow="{ row }">
<Switch :model-value="row.isShow" true-color="#5979ea" @on-change="onMenuBarShowChange(row.id)" />
</template>
<template #roleIds="{ row }">
<span>{{ row.roleIds.map((item: number) => RoleTypeDict.find(x => x.k == item)?.v).join(",") }}</span>
</template>
<!-- <template #orderNum="{ row }">
<InputNumber :model-value="row.orderNum" :min="1" />
</template> -->
</Table>
<template #footer>
<Button type="primary" ghost @click="isTableModalShow = false">关闭</Button>
</template>
</Modal>
<Modal
v-model="isEditModalShow"
scrollable
width="550"
:title="(!formData.id ? '添加' : '编辑') + '统计模块'"
:transfer="true"
>
<Form ref="refEditForm" class="mr-30" :model="formData" :rules="ruleValidate" :label-width="110">
<Form-Item label="权限角色:" class="fix" prop="roleIds">
<Checkbox
class="fl"
:indeterminate="indeterminate"
:model-value="compCheckAll"
@click.prevent="handleCheckAll"
>
全部
</Checkbox>
<CheckboxGroup v-model="formData.roleIds">
<Checkbox v-for="item in RoleTypeDict" :label="item.k">{{ item.v }}</Checkbox>
</CheckboxGroup>
</Form-Item>
<Form-Item label="名称:" prop="name">
<Input max="4" v-model="formData.name" maxlength="30" placeholder="请输入名称" />
</Form-Item>
<Form-Item label="网址:" prop="url">
<Input v-model="formData.url" placeholder="请输入链接" />
</Form-Item>
<Form-Item label="图标:" prop="icon">
<Upload
:on-success="uploadIconSuccess"
:show-upload-list="false"
:action="$conf.serviceHost + '/api/iconUpload'"
:format="['jpg', 'jpeg', 'png', 'gif']"
accept="image/gif,image/jpeg,image/jpg,image/png"
:with-credentials="true"
:headers="uploadHeaders"
>
<div v-if="!formData.icon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon
@click.stop="formData.icon = null"
class="cloase-btn"
name="widget-close"
size="18"
color="#fff"
/>
<img :src="$conf.serviceHost + formData.icon" alt="" />
</div>
</Upload>
</Form-Item>
<Form-Item label="排序号:" prop="orderNum">
<InputNumber style="width: 120px" v-model="formData.orderNum" :min="1" placeholder="请输入排序号" />
</Form-Item>
<Form-Item label="打开方式:" prop="linkOpenType">
<RadioGroup v-model="formData.linkOpenType" type="button" button-style="solid">
<Radio :label="item.k" v-for="item in linkOpenTypeDict">{{ item.v }}</Radio>
</RadioGroup>
</Form-Item>
</Form>
<template #footer>
<Button type="primary" :loading="editLoading" @click="onEditSave">保存</Button>
<Button @click="isEditModalShow = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import StatsBarApi from "@/api/StatsBarApi";
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeEnum, RoleTypeDict } from "@/types";
import Utils from "@/utils";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { Message } from "view-ui-plus";
import { ref, reactive, computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import { BaseDict } from "@/types/Basics";
import { find } from "lodash";
interface FormData {
id: string | null;
roleIds: number[];
name: string | null;
url: string | null;
linkOpenType: number;
icon: string | null;
orderNum: number | null;
isShow: boolean;
isEdit: boolean;
}
// 初始化 formData 的默认值
const defaultFormData: FormData = {
id: null,
roleIds: [], // 添加 roleIds 属性
name: "",
url: "",
linkOpenType: 0,
icon: "",
orderNum: null,
isShow: true,
isEdit: true,
};
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const refEditForm = ref<any>(null);
const isTableModalShow = ref(false),
isEditModalShow = ref(false),
tableLoading = ref(false),
editLoading = ref(false),
tableList = ref<any[]>([]),
formData = reactive(defaultFormData);
const ruleValidate = ref({
roleIds: { required: true, type: "array", message: "请选择权限角色", trigger: "change,submit" },
name: { required: true, message: "请输入名称", trigger: "blur" },
url: { required: true, message: "请输入网址", trigger: "blur" },
icon: { required: true, message: "请上传图标", trigger: "change,submit" },
});
const linkOpenTypeDict = computed(() => LinkOpenTypeDict.filter(x => x.k != LinkOpenTypeEnum.Modal));
const compCheckAll = computed(() => formData.roleIds.length == RoleTypeDict.length && RoleTypeDict.length > 0);
const indeterminate = computed(() => formData.roleIds.length > 0 && formData.roleIds.length < RoleTypeDict.length);
const menuBarColumns = ref([
{
title: "名称",
key: "name",
},
{
title: "图标",
slot: "icon",
},
{
title: "角色",
width: 150,
slot: "roleIds",
},
{
title: "地址",
key: "url",
width: 150,
tooltip: true,
},
{
title: "打开方式",
key: "linkType",
},
{
title: "是否显示",
width: 100,
align: "center",
slot: "isShow",
},
{
title: "排序号",
key: "orderNum",
align: "center",
},
{
title: "操作",
width: 120,
align: "center",
render: (h: any, params: any) => {
const disabled = !params.row.isEdit,
id = params.row.id;
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => onEditFormShow(id), disabled),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteMenu(id), disabled),
];
},
},
]);
async function show() {
isTableModalShow.value = true;
await getMenuBarList();
console.log("show");
}
async function getMenuBarList() {
tableLoading.value = true;
const list = await StatsBarApi.getStatsBarSchList();
list.forEach(element => {
element.linkType = find(linkOpenTypeDict.value, { k: element.linkOpenType })?.v;
});
tableList.value = list;
tableLoading.value = false;
}
function handleCheckAll() {
formData.roleIds = !compCheckAll.value ? RoleTypeDict.map(item => item.k) : [];
}
function uploadIconSuccess(response: any, file: any, fileListt: any) {
if (response.code == 0) formData.icon = response.data.url;
else Message.error("上传错误!");
// console.log(response, file, fileListt);
}
async function onEditFormShow(id?: number) {
await refEditForm.value?.resetFields();
isEditModalShow.value = false;
if (!id) {
formData.id = null;
formData.linkOpenType = LinkOpenTypeEnum.Open;
} else {
let find = tableList.value.find(x => x.id == id);
Object.assign(formData, find);
}
isEditModalShow.value = true;
}
async function onDeleteMenu(id: number) {
await MyConfirm({
title: "删除",
msg: "确定删除该统计模块吗?",
info: "删除后无法恢复!",
async onOk() {
await StatsBarApi.deleteStatsBar(id);
},
});
await getMenuBarList();
}
async function onEditSave() {
let validate: boolean = await refEditForm.value?.validate();
if (!validate) return false;
editLoading.value = true;
try {
await StatsBarApi.saveStatsBar(formData);
Message.success("操作成功!");
await getMenuBarList();
isEditModalShow.value = false;
} catch (error) {
Message.success("操作失败!");
} finally {
editLoading.value = false;
}
}
function onMenuBarShowChange(id: number) {
StatsBarApi.toggleStatsBarShow(id);
}
defineExpose({
show,
});
</script>
<style scoped>
.icon-box {
width: 20px;
height: 20px;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<RadioGroup v-model="compModel" type="button" @on-change="onGroupChange">
<radio :label="1">今日</radio>
<radio :label="2">本周</radio>
<radio :label="3">本月</radio>
<radio :label="4">本学期</radio>
<radio :label="5">累计</radio>
<radio :label="6">自定义</radio>
</RadioGroup>
<Date-Picker
class="ml-10"
style="width: 220px"
v-if="isDatePickerShow"
type="daterange"
separator=" 至 "
:options="options"
:editable="false"
v-model="cusDate"
@on-change="onDataChange"
placeholder="选择日期范围"
></Date-Picker>
</template>
<script lang="ts" setup>
import Utils from "@/utils";
import { ref, reactive, computed } from "vue";
const emits = defineEmits<{
(e: "update:modelValue", dataType: string): void;
}>();
const props = defineProps({
modelValue: {
type: String,
default: "3",
},
});
const cusDate = ref([]);
const options = reactive({
shortcuts: [
{
text: "前一周",
value: function () {
var end = new Date();
var start = new Date();
start.setDate(new Date().getDate() - 7);
return [start, end];
},
},
{
text: "前一月",
value: function () {
var end = new Date();
var start = new Date();
start.setMonth(new Date().getMonth() - 1);
return [start, end];
},
},
{
text: "前三月",
value: function () {
var end = new Date();
var start = new Date();
start.setMonth(new Date().getMonth() - 3);
return [start, end];
},
},
{
text: "前半年",
value: function () {
var end = new Date();
var start = new Date();
start.setMonth(new Date().getMonth() - 6);
return [start, end];
},
},
{
text: "前一年",
value: function () {
var end = new Date();
var start = new Date();
start.setMonth(new Date().getMonth() - 12);
return [start, end];
},
},
],
});
const isDatePickerShow = ref(false);
const compModel = computed({
get() {
if (!isNaN(Number(props.modelValue))) return Number(props.modelValue);
else return 6;
},
set(val) {
isDatePickerShow.value = val == 6;
if (val == 6) return;
if (!isNaN(Number(val))) emits("update:modelValue", val.toString());
},
});
function onGroupChange(type: number) {
cusDate.value = [];
if (type == 6) return;
emits("update:modelValue", type + "");
}
function onDataChange() {
if (cusDate.value.length == 0) return;
if (!cusDate.value[0] || !cusDate.value[1]) return;
const timeStr = `${Utils.dateFormat(cusDate.value[0], "yyyy/MM/dd")}-${Utils.dateFormat(
cusDate.value[1],
"yyyy/MM/dd"
)}`;
emits("update:modelValue", timeStr);
}
</script>
<style></style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="chart-wrap">
<Spin size="large" fix :show="loading">
<slot>
<SvgIcon name="loading" size="50" color="6280eb" />
<div class="table-spin-load">加载中</div>
</slot>
</Spin>
<div class="chart-box" v-show="!loading">
<Tabs type="card" v-model="tabActive" :animated="false" v-if="chartList.length > 0">
<TabPane v-for="item in chartList" :label="item.title">
<div class="chart-item" :id="item.id">123</div>
</TabPane>
</Tabs>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch, nextTick } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
interface Props {
chartList: Array<any>;
loading: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const { $echarts: echarts } = useCurrentInstance();
const tabActive = ref(0);
const pieChartColors = ref([
"#b2aefa",
"#21b664",
"#eeb329",
"#eae213",
"#20b6ab",
"#f79854",
"#ea5e45",
"#0978c9",
"#118eea",
"#68b9f1",
]);
watch(
() => props.loading,
(val: boolean) => {
if (!val) initChart(props.chartList[tabActive.value]);
},
{ immediate: true }
);
watch(
() => tabActive.value,
(val: number) => {
initChart(props.chartList[val]);
}
);
async function initChart(data: any) {
await nextTick();
console.log(data);
var chartDom = document.getElementById(data.id);
var myChart = echarts.init(chartDom);
var titles = data.list.map((x: any) => x.k);
var data = data.list.map((x: any) => {
return {
value: x.v,
name: x.k,
};
});
var option = {
title: {
text: data.title,
x: "center",
y: "center",
itemGap: 50,
textStyle: {
color: "#666",
fontSize: 12,
fontWeight: "normal",
},
subtextStyle: {
color: "#de4751",
fontSize: 12,
},
},
series: [
{
name: "数据来源",
type: "pie",
radius: ["50%", "70%"],
hoverAnimation: false,
data: data,
minAngle: 15,
avoidLabelOverlap: true,
itemStyle: {
normal: {
label: {
show: true,
formatter: "{b} \t{c}",
//formatter: '{b}'
},
labelLine: {
show: true,
},
},
emphasis: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
},
],
color: pieChartColors.value,
};
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
// option && myChart.setOption(option);
}
</script>
<style lang="scss" scoped>
.chart-wrap {
// border: solid 1px green;
position: relative;
.chart-box {
height: 100%;
}
.chart-item {
margin-top: 40px;
height: 300px;
}
}
::v-deep .table-spin-load {
color: #577afb;
}
</style>
+1 -1
View File
@@ -6,7 +6,7 @@ interface Props {
name?: string;
color?: string;
hoverColor?: string;
size?: string | number;
size?: string;
}
const props = withDefaults(defineProps<Props>(), {
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts" setup>
import { OperatorEnum } from "@/types";
import { PageData, PageParams } from "@/types/Basics";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
import SvgIcon from "@/components/SvgIcon.vue";
import { ref, onBeforeMount, onMounted, nextTick } from "vue";
interface ReplaceParams {
page: string;
count: string;
}
interface ReplaceResult {
list: string;
total: string;
}
/* 表格插件参数类型 */
interface TablePageProps {
dataApi: (params: PageParams) => Promise<any>;
columns: object[];
tableHeight?: number;
pageParams: PageParams | undefined;
dataFilter?: ((resp: object) => object[]) | undefined;
autoLoad?: boolean | undefined;
pageType?: "small" | "default";
replaceParams?: ReplaceParams; //替换传入参数
replaceResult?: ReplaceResult; //替换返回结果
}
const props = withDefaults(defineProps<TablePageProps>(), {
autoLoad: true,
pageType: "default",
});
const tableRef = ref();
const list = ref<any[]>([]),
selectList = ref<number[]>([]),
total = ref(0),
tableH = ref(0),
tableLoading = ref(false);
const params = ref<PageParams>({ page: 1, count: 10 });
onBeforeMount(async () => {
if (props.tableHeight) tableH.value = props.tableHeight;
else tableH.value = Utils.useTableHeight();
if (!!props.pageParams) params.value = props.pageParams;
if (props.autoLoad) await getData();
});
onMounted(() => {
window.onresize = _.debounce(() => {
tableH.value = Utils.useTableHeight();
}, 1000);
});
const getData = async () => {
tableLoading.value = true;
try {
let tempParams: any = _.cloneDeep(params.value);
if (!!props.replaceParams) {
const { page, count } = props.replaceParams;
tempParams = Utils.replaceField(tempParams, [
{ n: page, o: "page" },
{ n: count, o: "count" },
]);
}
var data = await props.dataApi(tempParams);
if (!!props.replaceResult) {
const { total, list } = props.replaceResult;
data = Utils.replaceField(data, [
{ n: "total", o: total },
{ n: "list", o: list },
]);
}
// console.log(data);
total.value = data.total;
if (typeof props.dataFilter != "undefined") list.value = props.dataFilter(data);
else list.value = data.list;
await nextTick();
// setTimeout(() => {
// console.log(tableRef.value.target);
// }, 1000);
//ivu-table-tbody
} catch (error) {
console.log(error);
Message.error(error);
} finally {
tableLoading.value = false;
}
};
function selectChange(selection: any) {
selectList.value = selection;
}
async function pageUpdate(page: number) {
params.value.page = page;
await getData();
}
async function pageSizeUpdate(size: number) {
params.value.page = 1;
params.value.count = size;
await getData();
}
/** 刷新数据-更新删除用 */
// "add" | "del" | "edit" | "sel"
async function refreshData() {
params.value.page = 1;
await getData();
}
/** 重置数据 */
// "add" | "del" | "edit" | "sel"
async function reloadData(type: OperatorEnum = OperatorEnum.sel) {
let { page, count } = params.value;
if (type == OperatorEnum.add) {
if (page * count == total.value) page += 1;
} else if (type == OperatorEnum.del) {
if ((page - 1) * count + 1 == total.value) page -= 1;
} else if (type == OperatorEnum.edit) {
//page=page
} else if (type == OperatorEnum.sel) page = 1;
params.value.page = page;
await getData();
}
defineExpose({
list,
refreshData,
reloadData,
});
</script>
<template>
<Table
:columns="columns"
:height="tableHeight"
:data="list"
ref="tableRef"
@on-selection-change="selectChange"
class="link-table"
:loading="tableLoading"
no-data-text="暂无数据"
>
<template #loading>
<SvgIcon name="loading" size="50" color="6280eb" />
<div class="table-spin-load">加载中</div>
</template>
</Table>
<div class="fix mt-10">
<Page
:size="pageType"
class="fr"
:total="total"
:model-value="params.page"
:page-size="params.count"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="pageSizeUpdate"
@on-change="pageUpdate"
show-total
:show-sizer="pageType == 'default'"
></Page>
</div>
</template>
<style lang="scss" scoped>
::v-deep .ivu-table::before {
content: none;
}
::v-deep .ivu-table-header thead tr th {
padding: 5px 0;
}
::v-deep .table-spin-load {
color: #577afb;
}
</style>
-119
View File
@@ -1,119 +0,0 @@
<template>
<!-- 表格 -->
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
</Table>
<!-- 分页 / 插槽 -->
<div class="page-panel">
<div class="fl ml-15 align-middle">
<slot :checkList="selectList" />
</div>
<Page class="page-box fr" :total="total" :model-value="page" :page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]" show-total show-sizer placement="top" @on-change="onPageChange"
@on-page-size-change="onSizeChange" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { Message } from 'view-ui-plus'
import Utils from '@/utils'
import { OperatorEnum } from '@/types'
interface Props {
/** 必填:alova-gen 生成的 selectTextbook 方法(或任意同形方法) */
dataApi: any
columns: any[]
dataParams: any
tableHeight?: number
autoLoad?: boolean
dataFilter?: (resp: any) => any[],
formatResponse?: (res: any) => { total: number; list: any[] }
}
const props = withDefaults(defineProps<Props>(), {
autoLoad: true,
formatResponse: (res: any) => ({ total: res.total, list: res.content })
})
const emit = defineEmits<{
(e: 'selection-change', list: number[]): void
}>()
onMounted(() => {
})
/* ---------------- 表格高度 ---------------- */
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
/* ---------------- 选中行 ---------------- */
const selectList = ref<number[]>([])
function selectChange(rows: any[]) {
selectList.value = rows.map((r: any) => r.id)
emit('selection-change', selectList.value)
}
/* ---------------- 分页 + 请求 ---------------- */
const {
loading,
data: resp,
send: sendLoad,
page,
pageSize,
total,
reload
} = usePagination(
(current: number, size: number) =>
props.dataApi({
...props.dataParams, // 合并外部条件
pageIndex: current,
pageSize: size,
}),
{
// total: res => res.total,
// data: res => res.list,
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
// 默认分页参数
initialPage: 1,
initialPageSize: 10,
immediate: props.autoLoad,
watchingStates: [() => props.dataParams]
}
)
/* 列表数据 */
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
/* ---------------- 翻页事件 ---------------- */
const onPageChange = (p: number) => (page.value = p)
const onSizeChange = (s: number) => (pageSize.value = s)
/* ---------------- 外部可调用 ---------------- */
function refreshData() {
page.value = 1
reload()
}
function reloadData(type: OperatorEnum = OperatorEnum.sel) {
let p = page.value
const s = pageSize.value
if (type === OperatorEnum.add && p * s === total.value) p += 1
if (type === OperatorEnum.del && (p - 1) * s + 1 === total.value) p -= 1
if (p < 1) p = 1
page.value = p
sendLoad()
}
defineExpose({ list, refreshData, reloadData })
</script>
<style scoped>
/* 沿用旧样式即可 */
</style>
-105
View File
@@ -1,105 +0,0 @@
<template>
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
<div class="page-panel">
<div class="fl align-middle">
<slot :checkList="selectList" />
</div>
<Page class="page-box fr" :total="total" :model-value="page" :page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]" show-total show-sizer placement="top" @on-change="onPageChange"
@on-page-size-change="onSizeChange" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, useSlots } from 'vue'
import Utils from '@/utils'
import { OperatorEnum } from '@/types'
interface Props {
dataApi: any
columns: any[]
tableHeight?: number
autoLoad?: boolean
dataFilter?: (resp: any) => any[]
formatResponse?: (res: any) => { total: number; list: any[] }
}
const props = withDefaults(defineProps<Props>(), {
autoLoad: true,
formatResponse: (res: any) => ({ total: res.total, list: res.content })
})
const emit = defineEmits<{
(e: 'selection-change', list: number[]): void
}>()
const slots = useSlots()
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
const selectList = ref<number[]>([])
function selectChange(rows: any[]) {
selectList.value = rows.map((r: any) => r.id)
emit('selection-change', selectList.value)
}
const {
loading,
data: resp,
send: sendLoad,
page,
pageSize,
total,
reload
} = usePagination(
(current: number, size: number) =>
props.dataApi({ pageIndex: current, pageSize: size }),
{
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
initialPage: 1,
initialPageSize: 10,
immediate: props.autoLoad,
}
)
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
const onPageChange = (p: number) => (page.value = p)
const onSizeChange = (s: number) => (pageSize.value = s)
function refreshData() {
page.value = 1
reload()
}
function reloadData(type: OperatorEnum = OperatorEnum.sel) {
let p = page.value
const s = pageSize.value
if (type === OperatorEnum.add && p * s === total.value) p += 1
if (type === OperatorEnum.del && (p - 1) * s + 1 === total.value) p -= 1
if (type === OperatorEnum.sel) p = 1
if (p < 1) p = 1
page.value = p
sendLoad()
}
defineExpose({ list, refreshData, reloadData })
</script>
<style scoped></style>
+317
View File
@@ -0,0 +1,317 @@
<template>
<Modal
v-model="isTableModalShow"
class-name="menuwapper-modal"
width="1160"
:mask="true"
title="教师助手设置"
:transfer="false"
>
<div class="fix mb-10">
<Button type="primary" size="small" class="fl ml-10 width-10" ghost @click="onEditFormShow()">
新建页面
</Button>
</div>
<Table
:columns="menuBarColumns"
:height="360"
:loading="tableLoading"
:data="tableList"
class="link-table"
no-data-text="暂无数据"
>
<template #icon="{ row }">
<img class="icon-box" v-if="!!row.icon" :src="$conf.serviceHost + row.icon" alt="" />
</template>
<template #isShow="{ row }">
<Switch :model-value="row.isShow" true-color="#5979ea" @on-change="onMenuBarShowChange(row.id)" />
</template>
<template #type="{ row }">
{{ teacherCopilotTypeDict.find(x => x.k == row.type)?.v }}
</template>
<template #roleIds="{ row }">
<span>{{ row.roleIds.map((item: number) => RoleTypeDict.find(x => x.k == item)?.v).join(",") }}</span>
</template>
<!-- <template #orderNum="{ row }">
<InputNumber :model-value="row.orderNum" :min="1" />
</template> -->
</Table>
<template #footer>
<Button type="primary" ghost @click="isTableModalShow = false">关闭</Button>
</template>
</Modal>
<Modal
v-model="isEditModalShow"
scrollable
width="550"
:title="(!formData.id ? '添加' : '编辑') + '页面'"
:transfer="true"
>
<Form ref="refEditForm" class="mr-30" :model="formData" :rules="ruleValidate" :label-width="110">
<Form-Item label="权限角色:" class="fix" prop="roleIds">
<Checkbox
class="fl"
:indeterminate="indeterminate"
:model-value="compCheckAll"
@click.prevent="handleCheckAll"
>
全部
</Checkbox>
<CheckboxGroup v-model="formData.roleIds">
<Checkbox v-for="item in RoleTypeDict" :label="item.k">{{ item.v }}</Checkbox>
</CheckboxGroup>
</Form-Item>
<Form-Item label="名称:" prop="name">
<Input max="4" v-model="formData.name" maxlength="30" placeholder="请输入名称" />
</Form-Item>
<Form-Item label="网址:" prop="url">
<Input v-model="formData.url" placeholder="请输入链接" />
</Form-Item>
<Form-Item label="图标:" prop="icon">
<Upload
:on-success="uploadIconSuccess"
:show-upload-list="false"
:action="$conf.serviceHost + '/api/iconUpload'"
:format="['jpg', 'jpeg', 'png', 'gif']"
accept="image/gif,image/jpeg,image/jpg,image/png"
:with-credentials="true"
:headers="uploadHeaders"
>
<div v-if="!formData.icon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon
@click.stop="formData.icon = null"
class="cloase-btn"
name="widget-close"
size="18"
color="#fff"
/>
<img :src="$conf.serviceHost + formData.icon" alt="" />
</div>
</Upload>
</Form-Item>
<Form-Item label="排序号:" prop="orderNum">
<InputNumber style="width: 120px" v-model="formData.orderNum" :min="1" placeholder="请输入排序号" />
</Form-Item>
<Form-Item label="打开方式:" prop="linkOpenType">
<RadioGroup v-model="formData.linkOpenType" type="button" button-style="solid">
<Radio :label="item.k" v-for="item in linkOpenTypeDict">{{ item.v }}</Radio>
</RadioGroup>
</Form-Item>
<Form-Item label="所属类别:" prop="type">
<RadioGroup v-model="formData.type" type="button" button-style="solid">
<Radio :label="item.k" v-for="item in teacherCopilotTypeDict">{{ item.v }}</Radio>
</RadioGroup>
</Form-Item>
</Form>
<template #footer>
<Button type="primary" :loading="editLoading" @click="onEditSave">保存</Button>
<Button @click="isEditModalShow = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import TeacherCopilotApi from "@/api/TeacherCopilotApi";
import { LinkOpenTypeDict, LinkOpenTypeEnum, TeacherCopilotTypeDict, RoleTypeEnum, RoleTypeDict } from "@/types";
import Utils from "@/utils";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { Message } from "view-ui-plus";
import { ref, reactive, computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import { find } from "lodash";
interface FormData {
id: string | null;
roleIds: number[];
name: string | null;
url: string | null;
linkOpenType: number;
type: number;
icon: string | null;
orderNum: number | null;
isShow: boolean;
isEdit: boolean;
}
// 初始化 formData 的默认值
const defaultFormData: FormData = {
id: null,
roleIds: [], // 添加 roleIds 属性
name: "",
url: "",
linkOpenType: 0,
type: 0,
icon: "",
orderNum: null,
isShow: true,
isEdit: true,
};
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const refEditForm = ref<any>(null);
const isTableModalShow = ref(false),
isEditModalShow = ref(false),
tableLoading = ref(false),
editLoading = ref(false),
tableList = ref<any[]>([]),
formData = reactive(defaultFormData);
const ruleValidate = ref({
roleIds: { required: true, type: "array", message: "请选择权限角色", trigger: "change,submit" },
name: { required: true, message: "请输入名称", trigger: "blur" },
url: { required: true, message: "请输入网址", trigger: "blur" },
icon: { required: true, message: "请上传图标", trigger: "change,submit" },
type: { required: true, message: "请选择类别", type: "number", trigger: "change,submit" },
});
const linkOpenTypeDict = computed(() => LinkOpenTypeDict.filter(x => x.k != LinkOpenTypeEnum.Modal));
const teacherCopilotTypeDict = computed(() => TeacherCopilotTypeDict);
const compCheckAll = computed(() => formData.roleIds.length == RoleTypeDict.length && RoleTypeDict.length > 0);
const indeterminate = computed(() => formData.roleIds.length > 0 && formData.roleIds.length < RoleTypeDict.length);
const menuBarColumns = ref([
{
title: "名称",
key: "name",
},
{
title: "图标",
slot: "icon",
},
{
title: "角色",
width: 150,
slot: "roleIds",
},
{
title: "地址",
key: "url",
width: 150,
tooltip: true,
},
{
title: "打开方式",
key: "linkType",
},
{
title: "类别",
slot: "type",
},
{
title: "是否显示",
width: 100,
align: "center",
slot: "isShow",
},
{
title: "排序号",
key: "orderNum",
align: "center",
},
{
title: "操作",
width: 120,
align: "center",
render: (h: any, params: any) => {
const disabled = !params.row.isEdit,
id = params.row.id;
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => onEditFormShow(id), disabled),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteMenu(id), disabled),
];
},
},
]);
async function show() {
isTableModalShow.value = true;
await getMenuBarList();
console.log("show");
}
async function getMenuBarList() {
tableLoading.value = true;
const list = await TeacherCopilotApi.getSchList();
list.forEach(element => {
element.linkType = find(linkOpenTypeDict.value, { k: element.linkOpenType })?.v;
});
tableList.value = list;
tableLoading.value = false;
}
function handleCheckAll() {
formData.roleIds = !compCheckAll.value ? RoleTypeDict.map(item => item.k) : [];
}
function uploadIconSuccess(response: any, file: any, fileListt: any) {
if (response.code == 0) formData.icon = response.data.url;
else Message.error("上传错误!");
// console.log(response, file, fileListt);
}
async function onEditFormShow(id?: number) {
await refEditForm.value?.resetFields();
isEditModalShow.value = false;
if (!id) {
formData.id = null;
formData.linkOpenType = LinkOpenTypeEnum.Open;
} else {
let find = tableList.value.find(x => x.id == id);
Object.assign(formData, find);
}
isEditModalShow.value = true;
}
async function onDeleteMenu(id: number) {
await MyConfirm({
title: "删除",
msg: "确定删除该页面吗?",
info: "删除后无法恢复!",
async onOk() {
await TeacherCopilotApi.delete(id);
},
});
await getMenuBarList();
}
async function onEditSave() {
let validate: boolean = await refEditForm.value?.validate();
if (!validate) return false;
editLoading.value = true;
try {
await TeacherCopilotApi.save(formData);
Message.success("操作成功!");
await getMenuBarList();
isEditModalShow.value = false;
} catch (error) {
Message.success("操作失败!");
} finally {
editLoading.value = false;
}
}
function onMenuBarShowChange(id: number) {
TeacherCopilotApi.toggleShow(id);
}
defineExpose({
show,
});
</script>
<style scoped>
.icon-box {
width: 20px;
height: 20px;
}
</style>
-169
View File
@@ -1,169 +0,0 @@
<template>
<div class="type_set-box">
<div class="type_name">
<i class="iconfont icon-stop fl mr-10" v-if="!isEdit && !active" style="color: #ff5959"></i>
<Input v-if="isEdit" v-model="type.name" ref="type_input" @on-blur="update" @on-enter="update" />
<span v-else class="fl">{{ type.name }}</span>
</div>
<div class="type_action flex">
<div class="action_item" @click="open">
<span v-if="active">停用</span>
<span v-else>启用</span>
</div>
<div class="action_edit" @click="edit">
<span>编辑</span>
</div>
<div class="action_delete" @click="remove">
<span>删除</span>
</div>
</div>
<div class="divider"></div>
<div class="divider2"></div>
</div>
</template>
<script>
export default {
name: "TypeSetBox",
props: {
type: {
name: { type: String, default: "" },
id: { type: String, default: "" },
},
active: { type: Boolean, default: true },
},
data() {
return {
isEdit: false,
eventType: "",
};
},
watch: {
type: {
deep: true,
handler(newVal) {
if (this.isEdit) {
this.isEdit = false;
}
},
},
"type.name"(newVal) {
if (this.isEdit) {
this.isEdit = false;
}
},
},
methods: {
open() {
this.$emit("active", this.type.id);
},
edit() {
this.isEdit = !this.isEdit;
if (this.isEdit) this.$nextTick(() => this.$refs["type_input"].focus());
},
update(e) {
if (!this.type.name) {
this.$Message.error("值不得为空!");
return;
}
if (this.eventType == "") {
this.eventType = e.type;
this.isEdit = false;
this.$emit("edit", this.type.id, this.type.name);
setTimeout(() => {
this.eventType = "";
}, 500);
}
},
remove() {
this.$emit("remove", this.type.id);
},
},
};
</script>
<style lang="scss" scoped>
.flex {
display: flex;
}
.type_set-box {
width: 222px;
background: #ffffff;
margin-right: 15px;
margin-bottom: 15px;
.type_name {
height: 66px;
border: 1px solid #e9e9e9;
border-bottom: none;
padding-left: 15px;
padding-right: 15px;
line-height: 66px;
display: flex;
justify-content: center;
align-items: center;
span {
height: 24px;
font-size: 15px;
font-family: PingFangSC-Semibold, PingFang SC;
font-weight: 500;
color: #272829;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 222px;
display: inline-block;
}
.iconfont {
font-size: 26px;
}
}
.type_action {
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 222px;
height: 30px;
line-height: 30px;
background: #f7f7f7;
border-radius: 0px 0px 2px 2px;
border-top: none;
border: 1px solid #e9e9e9;
span {
font-size: 12px;
}
div {
cursor: pointer;
flex: 1;
text-align: center;
}
div:hover {
color: #6c91f0;
}
}
.divider {
// position: relative;
height: 14px;
width: 1px;
background: #e8e8e8;
// left: 73px;
// top: -23px;
float: left;
margin-left: 71px;
margin-top: -22px;
}
.divider2 {
// position: relative;
// left: 146px;
// top: -37px;
height: 14px;
width: 1px;
background: #e8e8e8;
float: left;
margin-left: 147px;
margin-top: -22px;
}
}
</style>
-106
View File
@@ -1,106 +0,0 @@
<template>
<div class="improt-wrap">
<slot></slot>
<div class="upload-panel">
<Upload type="drag" :on-success="onSuccess" :on-remove="onRemove" :on-error="onError"
:before-upload="handleUploadBefore" :headers="headers" :data="uploadData" :action="uploadUrl"
:with-credentials="true" :max-size="maxSize" :multiple="multiple">
<div class="upload-box">
<Icon type="ios-paper" size="52" style="color: #6281eb"></Icon>
<h4>点击或将文件拖拽到这里上传</h4>
<div class="info">文件大小不超过{{ $utils.getFileSize(maxSize) }}</div>
</div>
<Alert v-if="!!errorMsg" type="error" show-icon><span v-html="errorMsg"></span></Alert>
</Upload>
</div>
<!-- <slot name="footer"></slot> -->
</div>
</template>
<script>
export default {
model: {
event: "update:fileList",
prop: "fileList",
},
props: {
fileList: {
type: Array,
default: [],
},
uploadUrl: String,
uploadData: Object,
headers: Object,
multiple: {
type: Boolean,
default: false,
},
},
data() {
return {
maxSize: 200 * 1024 * 1024,
errorMsg: null,
};
},
methods: {
handleUploadBefore(file) {
var _this = this;
if (file.size > _this.maxSize) {
_this.$Message.error({
content: "请上传小于" + _this.$utils.getFileSize(_this.maxSize) + "的文件!",
duration: 3,
});
}
},
onSuccess(res, file) {
if (res.code == 0) {
this.errorMsg = null;
const { uid } = file;
this.fileList.push({ ...res.data, uid });
this.$emit("update:fileList", this.fileList);
} else {
let msg = res.data.error;
this.errorMsg = msg;
}
},
onRemove(file, fileList) {
const { uid } = file;
const findIndex = this.fileList.findIndex(item => item.uid === uid);
this.fileList.splice(findIndex, 1);
this.$emit("update:fileList", this.fileList);
console.log(file, fileList);
},
onError(err, file) {
this.errorMsg = JSON.stringify(err);
console.log(err, file);
},
},
};
</script>
<style lang="scss" scoped>
.improt-wrap {
padding: 0 12px;
.form-item {
margin-bottom: 10px;
}
}
.upload-panel {
.upload-box {
background: #f7f8fc;
padding: 30px 0;
h4 {
color: #505c7e;
margin: 3px 0;
}
}
.info {
font-size: 13px;
color: #666;
}
}
</style>
-12
View File
@@ -35,7 +35,6 @@
:on-format-error="handleFormatError"
:on-exceeded-size="handleMaxSize"
:default-file-list="defaultList"
:headers="uploadHeader"
type="drag"
:action="uploadUrl"
:data="data"
@@ -78,17 +77,6 @@ export default {
console.log(this.$refs.upload.fileList);
this.uploadList = this.$refs.upload.fileList;
},
computed: {
uploadHeader() {
const bschUtil = BschLoadUtil.getInstance();
if (!bschUtil.getIsSelf()) {
return {
ticket: sessionStorage.getItem("bschTicket"),
platform: "grzx",
};
} else return null;
},
},
methods: {
handleView(name) {
this.imgName = name;
+238
View File
@@ -0,0 +1,238 @@
<template>
<Modal v-model="isUserInfoDetailsShow" width="500" title="个人信息" :transfer="false" class-name="userinfo-modal">
<div class="userinfo-panel fix" v-if="!!store.userInfo">
<Upload
class="avatar-upload"
:on-success="uploadAvatarSuccess"
:on-exceeded-size="onAvatarSizeExceeded"
:on-format-error="onAvatarFormatError"
:show-upload-list="false"
:action="$conf.serviceHost + '/api/avatarUpload'"
accept=".jpg,.jpeg,.png,.gif,.webp"
:format="['jpg', 'jpeg', 'png', 'gif', 'webp']"
:max-size="20480"
:with-credentials="true"
:headers="uploadHeaders"
>
<div class="avatar-wrap" title="点击更换头像">
<img :src="requireImg('user-avatar-default.png')" v-real-img="store.userInfo.profilePicture" alt="" />
<div class="avatar-mask">更换头像</div>
</div>
</Upload>
<ul>
<li>
<span>用户名:</span>
<span>{{ store.userInfo.account }}</span>
</li>
<li>
<span>真实姓名 :</span>
<span>{{ store.userInfo.name }}</span>
</li>
<li>
<span>手机号码 :</span>
<span>{{ store.userInfo.phone || "-" }}</span>
</li>
<li>
<span>身份证号 :</span>
<span>{{ store.userInfo.idcard || "-" }}</span>
</li>
</ul>
</div>
<template #footer>
<Button type="primary" class="fl" @click="onUpdatePasswordDetailsShow">修改密码</Button>
<Button type="primary" ghost @click="isUserInfoDetailsShow = false">关闭</Button>
</template>
</Modal>
<Modal v-model="isUpdatePasswordDetailsShow" width="450" title="修改密码" :transfer="false" class-name="userinfo-modal">
<div class="fix">
<Form ref="refEditForm" class="mr-20 mb-20" :model="formData" :rules="ruleValidate" :label-width="120">
<Form-Item label="旧密码:" prop="oldPassword">
<Input type="password" v-model="formData.oldPassword" maxlength="30" placeholder="请输入旧密码" />
</Form-Item>
<Form-Item label="新密码:" prop="password">
<Input type="password" v-model="formData.password" maxlength="30"
placeholder="8位以上密码,需包含字母、数字、符号" />
</Form-Item>
<Form-Item label="确认新密码:" prop="passwdCheck">
<Input type="password" v-model="formData.passwdCheck" maxlength="30" placeholder="请确认密码" />
</Form-Item>
</Form>
</div>
<template #footer>
<Button @click="isUpdatePasswordDetailsShow = false">取消</Button>
<Button type="primary" @click="onUpdatePasswordSave">确定</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, reactive } from "vue";
import { requireImg } from "@/utils/hook/useImg";
import { useDefaultStore } from "@/store";
import mittBus from "@/utils/mitt";
import BaseApi from "@/api/BaseApi";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
interface FormData {
oldPassword: string;
password: string;
passwdCheck: string;
}
const { $conf } = useCurrentInstance();
const store = useDefaultStore();
const isUserInfoDetailsShow = ref(false);
const isUpdatePasswordDetailsShow = ref(false);
const formData = reactive<FormData>({
oldPassword: "",
password: "",
passwdCheck: "",
});
const refEditForm = ref<any>(null);
const validatePass = (rule: any, value: any, callback: Function) => {
if (value === "") {
callback(new Error("请输入新密码!"));
} else if (value === formData.oldPassword) {
callback(new Error("新密码不能与旧密码一致!"));
} else if (value.length < 8) {
callback(new Error("密码长度不能小于8位!"));
} else if (!/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/.test(value)) {
callback(new Error("密码需包含字母、数字、符号!"));
} else {
if (formData.passwdCheck !== "") {
// 对第二个密码框单独验证
refEditForm.value.validateField("passwdCheck");
}
callback();
}
};
const validatePassCheck = (rule: any, value: any, callback: Function) => {
if (value === "") {
callback(new Error("请重新输入密码!"));
} else if (value !== formData.password) {
callback(new Error("两次密码输入不一致!"));
} else {
callback();
}
};
const ruleValidate = ref({
oldPassword: { required: true, message: "请输入旧密码", trigger: "change,submit" },
password: [{ required: true, validator: validatePass, trigger: "blur" }],
passwdCheck: [{ required: true, validator: validatePassCheck, trigger: "blur" }],
});
onMounted(function () {
mittBus.on("showUserInfoModal", (state?: boolean) => {
if (typeof state === "undefined") isUserInfoDetailsShow.value = true;
else isUserInfoDetailsShow.value = state;
});
});
function onUpdatePasswordDetailsShow() {
refEditForm.value.resetFields();
isUpdatePasswordDetailsShow.value = true;
}
// ==================== 头像上传 ====================
// Upload 组件直传不经 axios 封装,需手动补网关校验头(对齐 MenuBarModal 写法)
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
function uploadAvatarSuccess(response: any) {
if (response.code == 0 && response.data && response.data.url) {
store.userInfo.profilePicture = response.data.url;
Message.success("头像更新成功!");
} else {
Message.error("头像更新失败:" + (response.msg || "未知错误"));
}
}
function onAvatarSizeExceeded() {
Message.error("图片大小不能超过20MB!");
}
function onAvatarFormatError() {
Message.error("仅支持 jpg/jpeg/png/gif/webp 格式图片!");
}
async function onUpdatePasswordSave() {
const valid = refEditForm.value.validate();
if (!valid) return false;
try {
await BaseApi.updatePassword(formData.oldPassword, formData.password);
Message.success("操作成功!");
setTimeout(() => {
logout();
}, 1500);
} catch (error) {
Message.error("操作失败:" + error);
}
// let res = await store.updatePassword(formData);
}
function logout() {
let url = window.location.href;
url = "/cas/logout?service=" + url;
url = $conf.ssoHost + url;
if (!!window.top) window.top.location.href = url;
else window.location.href = url;
}
onUnmounted(function () {
mittBus.off("showUserInfoModal");
});
</script>
<style lang="scss" scoped>
.userinfo-panel {
.avatar-upload {
float: left;
cursor: pointer;
}
.avatar-wrap {
position: relative;
width: 120px;
height: 120px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.avatar-mask {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 4px 0;
text-align: center;
font-size: 12px;
color: #fff;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.2s;
}
&:hover .avatar-mask {
opacity: 1;
}
ul {
float: left;
margin-left: 16px;
height: 120px;
li {
line-height: 30px;
}
}
}
</style>
-259
View File
@@ -1,259 +0,0 @@
<script setup lang="ts">
import { ref, inject, onBeforeMount, watch, toRefs, nextTick, computed } from "vue";
import { LayoutData } from "@/types/Basics";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
let props = defineProps<{ layoutData: LayoutData }>();
const reload: Function = inject("reload") || new Function("console.error('empty')");
const appPanel = ref();
let route = useRoute();
const router = useRouter();
const isPanel = ref(false),
currPath = ref<string>(""),
navPath = ref<any[]>([]);
const { baseHost, ssoHost, appName } = props.layoutData;
const { appList, userInfo, isAlone, modList } = toRefs(props.layoutData);
const modArr = computed(() => modList.value)
const { $bus } = useCurrentInstance();
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
onBeforeMount(async () => {
// 初次加载时手动同步一次(参考 bschstandard created 中手动触发 routeChange)
routeChange(route);
});
// 路由变化时实时同步左侧高亮与面包屑
watch(
() => route.fullPath,
() => routeChange(route)
);
/**
* 将菜单树扁平化(参考 bschstandard.frame.layout 的 flaten)
* - 子节点携带父节点的 id 作为 pid,用于面包屑与高亮联动
*/
const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = [];
tree.forEach(element => {
const { id, name, page, modId } = element;
arr.push({ id, name, page, modId, pid });
if (element.children && element.children.length) {
arr.push(...flaten(element.children, id));
}
});
return arr;
};
const menuArr = computed(() => flaten(modList.value || []));
/**
* 同步当前路由对应的左侧高亮项与面包屑
* - ① 命中菜单树 modId:高亮自身;分组下的子项面包屑为「父级 / 自身」
* - ② 未命中但带 meta.parentId 的深层子页(如 应用类别管理):
* 用 parentId 找父菜单 → 高亮父菜单、面包屑为「(祖先/)父级(可点) / 当前页(不可点)」
* —— 对应效果「应用管理 > 应用类别管理」(参考 bschstandard.frame.layout 的 parentId 回退)
* - ③ 未在菜单树中的旧路由(/subject 等)降级按路由表兜底,仅展示自身
*/
const routeChange = (to: any) => {
const meta: any = to.meta || {};
const { modId, title, parentId } = meta;
const navArr: any[] = [];
// ① 命中菜单节点
const find: any = menuArr.value.find(x => x.modId === modId);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
const pfind = menuArr.value.find(x => x.id === find.pid);
if (pfind) navArr.push({ name: pfind.name });
}
navArr.push({ name: find.name || title, page: find.page });
navPath.value = navArr;
return;
}
// ② 深层子页:按 meta.parentId 找父菜单,高亮父菜单并展开多级面包屑
if (parentId) {
const parent = menuArr.value.find(x => x.modId === parentId);
if (parent) {
currPath.value = parent.page || ""; // 高亮父菜单(如「应用管理」)
if (parent.pid) {
const gp = menuArr.value.find(x => x.id === parent.pid);
if (gp) navArr.push({ name: gp.name }); // 祖先(若父菜单本身在分组下)
}
navArr.push({ name: parent.name, page: parent.page }); // 父级可点击
navArr.push({ name: title }); // 当前深层页,不可点击
navPath.value = navArr;
return;
}
}
// ③ 旧路由兜底:路由表中带 modId 的,仅展示自身
const r = router.getRoutes().find(rt => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
return;
}
currPath.value = "";
navPath.value = [];
};
const showPanel = async () => {
if (appList.value.length == 0) {
Message.info("暂无常用应用!");
return;
}
isPanel.value = false;
await nextTick();
isPanel.value = true;
await nextTick();
var showPanelClick = () => {
appPanel.value.onclick = (e: any) => {
e.stopPropagation();
};
document.addEventListener(
"click",
() => {
isPanel.value = false;
},
{ once: true }
);
};
setTimeout(showPanelClick, 0);
};
const linkMenu = (page: string) => {
const path = route.path;
if (page === path) reload();
else router.push(page);
};
const linkUserInfo = () => {
window.location.href = baseHost + "/v/userinfo";
};
const logout = () => {
let url = window.location.href;
url = "/cas/logout?service=" + url;
window.location.href = ssoHost + url;
};
const childChange = (index: number) => {
modArr.value[index].childState = !modArr.value[index].childState;
};
function linkHomePage() {
//@ts-ignore
const bschUtil = BschLoadUtil.getInstance();
if (!bschUtil.getIsSelf()) {
bschUtil.goBackDesktop();
} else {
window.location.href = baseHost + "/BschHome/front/index.html";
}
}
</script>
<template>
<div class="layout-wrap">
<header class="header-container fix">
<div class="fl align-middle icon-box">
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="showPanel">
<i class="iconfont icon-apps"></i>
</div>
</div>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }}
</a>
<div class="delimiter fl align-middle"></div>
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
<i class="iconfont icon-logout"></i>
</a>
</div>
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
<ul>
<template v-for="(item, index) in modArr">
<template v-if="item.children && item.children.length > 0">
<li>
<a href="javascript:;" @click="childChange(index)">
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
{{ item.name }}
</a>
</li>
<transition name="fade_child_ul">
<div class="child-item-wrap" v-show="item.childState">
<li :class="citem.page == currPath ? 'active child-item' : 'child-item'"
v-for="citem in item.children">
<a href="javascript:;" @click="linkMenu(citem.page)">
{{ citem.name }}
</a>
</li>
</div>
</transition>
</template>
<li v-else :class="item.page == currPath ? 'active' : ''" :key="'menu_li_' + index">
<a href="javascript:;" @click="linkMenu(item.page)">
{{ item.name }}
</a>
</li>
</template>
</ul>
<div class="clear-box"></div>
</div>
<div class="path-nav" :style="pathNavStyle">
<Breadcrumb separator=">">
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<i class="iconfont icon-category"></i>
首页
</BreadcrumbItem>
</a>
<BreadcrumbItem v-if="!layoutData.isAlone" to="/">{{ appName }}</BreadcrumbItem>
<template v-for="(item, index) in navPath">
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
{{ item.name }}
</BreadcrumbItem>
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
</template>
</Breadcrumb>
</div>
<transition name="fade_apps">
<div class="app-panel" ref="appPanel" v-if="isPanel">
<div class="tips-box"></div>
<ul class="fix">
<li class="fl" v-for="item in appList">
<a :href="item.url" target="_bank" rel="noopener noreferrer">
<div class="icon-box">
<img :src="item.icon" alt="" />
</div>
<p class="text-ellipsis">{{ item.name }}</p>
</a>
</li>
</ul>
<div class="link-panel">
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_bank" rel="noopener noreferrer">
全部应用
<i>→</i>
</a>
</div>
</div>
</transition>
</div>
</template>