init: 初始系统模板

This commit is contained in:
TsMask
2023-09-05 14:38:23 +08:00
parent a5bc16ae4f
commit 1075c8ae4f
130 changed files with 22531 additions and 1 deletions

212
src/layouts/BasicLayout.vue Normal file
View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import {
ProLayout,
GlobalFooter,
WaterMark,
getMenuData,
clearMenuItem,
} from '@ant-design-vue/pro-layout';
import RightContent from './components/RightContent.vue';
import Tabs from './components/Tabs.vue';
import { scriptUrl } from '@/assets/js/icon_font_8d5l8fzk5b87iudi';
import { computed, reactive, watch } from 'vue';
import useLayoutStore from '@/store/modules/layout';
import useAppStore from '@/store/modules/app';
import useRouterStore from '@/store/modules/router';
import useTabsStore from '@/store/modules/tabs';
import { useRouter } from 'vue-router';
import { MENU_PATH_INLINE } from '@/constants/menu-constants';
const { proConfig, waterMarkContent } = useLayoutStore();
const { appName } = useAppStore();
const routerStore = useRouterStore();
const tabsStore = useTabsStore();
const router = useRouter();
/**菜单面板 */
let layoutState = reactive({
collapsed: false, // 是否展开菜单面板
openKeys: ['/'], // 打开菜单key
selectedKeys: ['/index'], // 选中高亮菜单key
});
/**监听路由变化改变菜单面板选项 */
watch(
router.currentRoute,
v => {
const matched = v.matched.concat();
layoutState.openKeys = matched
.filter(r => r.path !== v.path)
.map(r => r.path);
layoutState.selectedKeys = matched
.filter(r => r.name !== 'Root')
.map(r => r.path);
// 路由地址含有内嵌地址标识又是隐藏菜单需要处理打开高亮菜单栏
if (v.path.includes(MENU_PATH_INLINE) && v.meta.hideInMenu) {
const idx = v.path.lastIndexOf(MENU_PATH_INLINE);
layoutState.openKeys.splice(-1);
layoutState.selectedKeys[matched.length - 1] = v.path.slice(0, idx);
}
},
{ immediate: true }
);
// 动态路由添加到菜单面板
const rootRoute = router.getRoutes().find(r => r.name === 'Root');
if (rootRoute) {
const children = routerStore.setRootRouterData(rootRoute.children);
const buildRouterData = routerStore.buildRouterData;
if (buildRouterData.length > 0) {
rootRoute.children = children.concat(buildRouterData);
} else {
rootRoute.children = children;
}
}
const { menuData } = getMenuData(clearMenuItem(router.getRoutes()));
/**面包屑数据对象,排除根节点和首页不显示 */
const breadcrumb = computed(() => {
const matched = router.currentRoute.value.matched.concat();
// 菜单中隐藏子节点不显示面包屑
if (matched.length == 2) {
const hideChildInMenu = matched[0].meta?.hideChildInMenu || false;
if (hideChildInMenu) {
return [];
}
}
return matched
.filter(r => !['Root', 'Index'].includes(r.name as string))
.map(item => {
return {
path: item.path,
breadcrumbName: item.meta.title || '-',
};
});
});
/**
* 给页面组件设置路由名称
*
* 路由名称设为缓存key
* @param component 页面组件
* @param name 路由名称
*/
function fnComponentSetName(component: any, to: any) {
if (component && component.type) {
// 通过路由取最后匹配的,确认是缓存的才处理
const matched = to.matched.concat();
const lastRoute = matched[matched.length - 1];
if (!lastRoute.meta.cache) return component;
const routeName = lastRoute.name;
const routeDef = lastRoute.components.default;
// 有命名但不是跳转的路由文件
const __name = component.type.__name;
if (__name && __name !== routeName) {
routeDef.name = routeName;
routeDef.__name = routeName;
Reflect.set(component, 'type', routeDef);
return component;
}
}
return component;
}
// 清空导航栏标签
tabsStore.clear();
</script>
<template>
<WaterMark :content="waterMarkContent" :z-index="100">
<ProLayout
v-model:collapsed="layoutState.collapsed"
v-model:selectedKeys="layoutState.selectedKeys"
v-model:openKeys="layoutState.openKeys"
:menu-data="menuData"
:breadcrumb="{ routes: breadcrumb }"
disable-content-margin
v-bind="proConfig"
:iconfont-url="scriptUrl"
>
<!--插槽-菜单头-->
<template #menuHeaderRender>
<RouterLink :to="{ name: 'Index' }" :replace="true">
<img class="logo" src="@/assets/logo.png" />
<h1>{{ appName }}</h1>
</RouterLink>
</template>
<!--插槽-顶部左侧只对side布局有效-->
<template #headerContentRender></template>
<!--插槽-顶部右侧-->
<template #rightContentRender>
<RightContent />
</template>
<!--插槽-导航标签项-->
<template #tabRender="{ width, fixedHeader, headerRender }">
<Tabs
:width="width"
:fixed-header="fixedHeader"
:header-render="headerRender"
/>
</template>
<!--插槽-页面路由导航面包屑-->
<template #breadcrumbRender="{ route, params, routes }">
<span v-if="routes.indexOf(route) === routes.length - 1">
{{ route.breadcrumbName }}
</span>
<RouterLink v-else :to="{ path: route.path, params }">
{{ route.breadcrumbName }}
</RouterLink>
</template>
<!--内容页面视图-->
<RouterView v-slot="{ Component, route }">
<transition name="slide-left" mode="out-in">
<KeepAlive :include="tabsStore.getCaches">
<component
:is="fnComponentSetName(Component, route)"
:key="route.path"
/>
</KeepAlive>
</transition>
</RouterView>
<!--插槽-内容底部-->
<template #footerRender>
<GlobalFooter
:links="[
{
blankTarget: true,
title: '开发手册',
href: 'https://juejin.cn/column/7188761626017792056',
},
{
blankTarget: true,
title: '开源仓库',
href: 'https://gitee.com/TsMask/',
},
{
blankTarget: true,
title: '接口文档',
href: 'https://mask-api-midwayjs.apifox.cn/',
},
]"
copyright="Copyright © 2023 Gitee For TsMask"
>
</GlobalFooter>
</template>
</ProLayout>
</WaterMark>
</template>
<style lang="less" scoped>
.logo {
height: 32px;
vertical-align: middle;
border-style: none;
border-radius: 6.66px;
}
</style>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
// 承载目录下级菜单页面需要声明才会生成name
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { isValid, decode } from 'js-base64';
import { useRoute } from 'vue-router';
import { reactive, ref } from 'vue';
import { validHttp } from '@/utils/regular-utils';
const route = useRoute();
const height = ref<string>(document.documentElement.clientHeight - 94.5 + 'px');
let iframe = reactive({
id: 'link',
src: '',
});
// 设置Frame窗口名称并设置链接地址
if (route.name) {
const name = route.name.toString();
const pathArr = route.matched.concat().map(i => i.path);
const pathLen = pathArr.length;
let path = pathArr[pathLen - 1].replace(pathArr[pathLen - 2], '');
path = path.substring(1);
if (isValid(path)) {
const url = decode(path);
if (validHttp(url)) {
iframe.src = url;
} else {
let endS = name.substring(4, 5).endsWith('s');
iframe.src = `${endS ? 'https://' : 'http://'}${url}`;
}
}
iframe.id = name;
}
</script>
<template>
<div :style="'height:' + height">
<iframe
:id="iframe.id"
:src="iframe.src"
frameborder="no"
style="width: 100%; height: 100%"
scrolling="auto"
></iframe>
</div>
</template>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { MenuInfo } from 'ant-design-vue/lib/menu/src/interface';
import { useRouter } from 'vue-router';
import useI18n from '@/hooks/useI18n';
import useUserStore from '@/store/modules/user';
const { t, changeLocale } = useI18n();
const userStore = useUserStore();
const router = useRouter();
/**头像展开项点击 */
function fnClick({ key }: MenuInfo) {
switch (key) {
case 'settings':
router.push({ name: 'Settings' });
break;
case 'profile':
router.push({ name: 'Profile' });
break;
case 'logout':
userStore.fnLogOut().finally(() => router.push({ name: 'Login' }));
break;
}
}
/**改变多语言 */
function fnChangeLocale(e: any) {
changeLocale(e.key);
}
</script>
<template>
<a-space :size="12" align="center">
<a-popover
overlayClassName="head-notice"
placement="bottomRight"
:trigger="['click']"
>
<a-button type="text">
<template #icon>
<a-badge :count="123" :overflow-count="99">
<BellOutlined :style="{ fontSize: '20px' }" />
</a-badge>
</template>
</a-button>
<template #content :style="{ padding: 0 }">
<a-tabs centered :tabBarStyle="{ width: '336px' }">
<a-tab-pane key="1" tab="通知41">
Content of Tab 通知
<a-button type="link" block>查看更多</a-button>
</a-tab-pane>
<a-tab-pane key="2" tab="消息231">
Content of Tab 消息
<a-button type="link" block>查看更多</a-button>
</a-tab-pane>
<a-tab-pane key="3" tab="待办1">
Content of Tab 待办
<a-button type="link" block>查看更多</a-button>
</a-tab-pane>
</a-tabs>
</template>
</a-popover>
<a-tooltip>
<template #title>开源仓库</template>
<a-button type="text" href="https://gitee.com/TsMask" target="_blank">
<template #icon>
<GithubOutlined :style="{ fontSize: '20px' }" />
</template>
</a-button>
</a-tooltip>
<a-tooltip>
<template #title>文档手册</template>
<a-button
type="text"
href="https://juejin.cn/column/7188761626017792056"
target="_blank"
>
<template #icon>
<QuestionCircleOutlined :style="{ fontSize: '20px' }" />
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click', 'hover']">
<a-button size="small" type="default">
{{ t('i18n') }}
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="fnChangeLocale">
<a-menu-item key="zh_CN">中文</a-menu-item>
<a-menu-item key="en_US">English</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
<div class="user">
<a-avatar
shape="circle"
size="default"
:src="userStore.getAvatar"
:alt="userStore.userName"
></a-avatar>
<span class="nick">
{{ userStore.nickName }}
</span>
</div>
<template #overlay>
<a-menu @click="fnClick">
<a-menu-item key="profile">
<template #icon>
<UserOutlined />
</template>
<span>个人中心</span>
</a-menu-item>
<a-menu-item key="settings">
<template #icon>
<SettingOutlined />
</template>
<span>个人设置</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<template #icon>
<LogoutOutlined />
</template>
<span>退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
<style lang="less" scoped>
.user {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
cursor: pointer;
.nick {
padding-left: 8px;
padding-right: 16px;
font-size: 16px;
max-width: 164px;
white-space: nowrap;
text-align: start;
text-overflow: ellipsis;
overflow: hidden;
}
}
</style>

View File

@@ -0,0 +1,187 @@
<script lang="ts" setup>
import IconFont from '@/components/IconFont/index.vue';
import { computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import useTabsStore from '@/store/modules/tabs';
const tabsStore = useTabsStore();
const router = useRouter();
defineProps({
/**标签栏宽度 */
width: {
type: String,
default: '100%',
},
/**是否固定顶部栏 */
fixedHeader: {
type: Boolean,
default: false,
},
/**是否有顶部栏 */
headerRender: {
type: [Object, Function, Boolean],
default: undefined,
},
});
/**导航标签项列表长度 */
let tabLen = computed(() => tabsStore.getTabs.length);
/**
* 标签更多菜单项
* @param key 菜单key
*/
function fnTabMenu(key: string | number) {
const route = router.currentRoute.value;
// 刷新当前
if (key === 'reload') {
const name = (route.name && route.name.toString()) || '';
tabsStore.cacheDelete(name);
router.replace({
path: `/redirect${route.path}`,
query: route.query,
});
}
// 关闭当前
if (key === 'current') {
const to = tabsStore.tabClose(route.path);
if (!to) return;
// 避免重复跳转
if (route.path === to.path) {
tabsStore.tabOpen(route);
} else {
router.push(to);
}
}
// 关闭其他
if (key === 'other') {
tabsStore.clear();
tabsStore.tabOpen(route);
}
// 关闭全部
if (key === 'all') {
tabsStore.clear();
// 已经是首页的避免重复跳转,默认返回首页
if (route.path === '/index') {
tabsStore.tabOpen(route);
} else {
router.push({ name: 'Index' });
}
}
}
/**
* 导航标签点击
* @param path 标签的路由路径
*/
function fnTabClick(path: string) {
const to = tabsStore.tabGoto(path);
if (!to) return;
router.push(to);
}
/**
* 导航标签关闭
* @param path 标签的路由路径
*/
function fnTabClose(path: string) {
const to = tabsStore.tabClose(path);
if (!to) return;
router.push(to);
}
/**监听当前路由添加到导航标签列表 */
watch(router.currentRoute, v => tabsStore.tabOpen(v), { immediate: true });
</script>
<template>
<div>
<header class="ant-layout-header tabs-header" v-if="fixedHeader"></header>
<a-tabs
class="tabs"
:class="{ 'tabs-fixed': fixedHeader }"
:style="{ width: width, top: headerRender === false ? 0 : undefined }"
hide-add
tab-position="top"
type="editable-card"
:tab-bar-gutter="8"
:tab-bar-style="{ margin: '0', height: '28px', lineHeight: '28px' }"
v-model:activeKey="tabsStore.activePath"
@tab-click="path => fnTabClick(path as string)"
@edit="path => fnTabClose(path as string)"
>
<a-tab-pane
v-for="tab in tabsStore.getTabs"
:key="tab.path"
:closable="tabLen > 1"
>
<template #tab>
<span>
<IconFont :type="tab.icon" style="margin: 0"></IconFont>
{{ tab.title }}
</span>
</template>
</a-tab-pane>
<template #rightExtra>
<a-space :size="8" align="end">
<a-tooltip placement="topRight">
<template #title>刷新当前</template>
<a-button
type="ghost"
shape="circle"
size="small"
@click="fnTabMenu('reload')"
>
<template #icon><ReloadOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip placement="topRight">
<template #title>更多选项</template>
<a-dropdown :trigger="['click', 'hover']" placement="bottomRight">
<a-button type="ghost" shape="circle" size="small">
<template #icon><DownOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="({ key }) => fnTabMenu(key)">
<a-menu-item key="current">关闭当前</a-menu-item>
<a-menu-item key="other">关闭其他 </a-menu-item>
<a-menu-item key="all">关闭全部</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-tooltip>
</a-space>
</template>
</a-tabs>
</div>
</template>
<style lang="less" scoped>
.tabs-header {
height: 36px;
line-height: 36px;
background: transparent;
}
.tabs {
z-index: 16;
margin: 0px;
padding: 4px 16px;
width: calc(100% - 208px);
background: #fff;
box-shadow: 0 1px 4px #0015291f;
transition: background 0.3s, width 0.2s;
position: relative;
&.tabs-fixed {
right: 0px;
top: 48px;
position: fixed;
}
}
.tabs :deep(.ant-tabs-nav:before) {
border-bottom: none;
}
</style>