Merge pull request #98 from Tencent/feat/multiple-tabs-support

feat: 支持多标签tab页的功能
This commit is contained in:
PY 2022-03-24 23:46:36 +08:00 committed by GitHub
commit f2d9752190
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 406 additions and 65 deletions

4
.env
View File

@ -1,3 +1,3 @@
VITE_SOME_KEY=123
# 打包路径
VITE_BASE_URL = /
# 打包路径 根据项目不同按需配置
VITE_BASE_URL = ./

View File

@ -15,10 +15,7 @@
"defineProps": "readonly",
"defineEmits": "readonly"
},
"plugins": [
"vue",
"@typescript-eslint"
],
"plugins": ["vue", "@typescript-eslint"],
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module",
@ -28,27 +25,22 @@
}
},
"settings": {
"import/extensions": [
".js",
".jsx",
".ts",
".tsx"
]
"import/extensions": [".js", ".jsx", ".ts", ".tsx"]
},
"rules": {
"no-console": "off",
"no-continue": "off",
"no-restricted-syntax": "off",
"no-plusplus": "off",
"no-param-reassign": "off",
"no-shadow": "off",
"no-param-reassign": "off",
"no-shadow": "off",
"guard-for-in": "off",
"import/extensions": "off",
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": "off",
"import/prefer-default-export": "off",
"import/first": "off", // https://github.com/vuejs/vue-eslint-parser/issues/58
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"vue/first-attribute-linebreak": 0
@ -61,8 +53,7 @@
"vue/require-default-prop": 0,
"vue/multi-word-component-names": 0,
"vue/no-reserved-props": 0,
"vue/no-v-html": 0,
"vue/no-v-html": 0
}
}
]

View File

@ -8,6 +8,7 @@ export default {
isFooterAside: false,
isSidebarFixed: true,
isHeaderFixed: true,
isUseTabsRouter: false,
showHeader: true,
backgroundTheme: 'blueGrey',
brandTheme: 'default',

View File

@ -1,3 +1,4 @@
import { RouteRecordName } from 'vue-router';
import STYLE_CONFIG from '@/config/style';
export interface ResDataType {
@ -37,3 +38,17 @@ export interface NotificationItem {
date: string;
quality: string;
}
export interface TRouterInfo {
path: string;
routeIdx?: number;
title?: string;
name?: RouteRecordName;
isAlive?: boolean;
isHome?: boolean;
}
export interface TTabRouterType {
isRefreshing: boolean;
tabRouterList: Array<TRouterInfo>;
}

View File

@ -1,10 +1,30 @@
<template>
<router-view v-slot="{ Component }">
<router-view v-if="!isRefreshing" v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
<keep-alive :include="aliveViews">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup lang="ts">
import { computed, ComputedRef } from 'vue';
import { useTabsRouterStore } from '@/store';
const aliveViews = computed(() => {
const tabsRouterStore = useTabsRouterStore();
const { tabRouters } = tabsRouterStore;
return tabRouters.filter((route) => route.isAlive).map((route) => route.name);
}) as ComputedRef<string[]>;
const isRefreshing = computed(() => {
const tabsRouterStore = useTabsRouterStore();
const { refreshing } = tabsRouterStore;
return refreshing;
});
</script>
<style lang="less" scoped>
@import '@/style/variables';

View File

@ -1,16 +1,17 @@
import { defineComponent, computed } from 'vue';
import { defineComponent, computed, nextTick, onMounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { usePermissionStore, useSettingStore } from '@/store';
import { useRoute, useRouter } from 'vue-router';
import { usePermissionStore, useSettingStore, useTabsRouterStore } from '@/store';
import TDesignHeader from './components/Header.vue';
import TDesignBreadcrumb from './components/Breadcrumb.vue';
import TDesignFooter from './components/Footer.vue';
import TDesignSideNav from './components/SideNav';
import TDesignContent from './components/Content.vue';
import LayoutHeader from './components/Header.vue';
import LayoutBreadcrumb from './components/Breadcrumb.vue';
import LayoutFooter from './components/Footer.vue';
import LayoutSideNav from './components/SideNav';
import LayoutContent from './components/Content.vue';
import Setting from './setting.vue';
import { prefix } from '@/config/global';
import TdesignSetting from './setting.vue';
import '@/style/layout.less';
const name = `${prefix}-base-layout`;
@ -19,8 +20,10 @@ export default defineComponent({
name,
setup() {
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionStore();
const settingStore = useSettingStore();
const tabsRouterStore = useTabsRouterStore();
const { routers: menuRouters } = storeToRefs(permissionStore);
const setting = storeToRefs(settingStore);
@ -57,10 +60,59 @@ export default defineComponent({
return newMenuRouters;
});
const appendNewRoute = () => {
const {
path,
meta: { title },
name,
} = route;
tabsRouterStore.appendTabRouterList({ path, title: title as string, name, isAlive: true });
};
onMounted(() => {
appendNewRoute();
});
watch(
() => route.path,
() => {
appendNewRoute();
},
);
const handleRemove = ({ value: path, index }) => {
const { tabRouters } = tabsRouterStore;
const nextRouter = tabRouters[index + 1] || tabRouters[index - 1];
tabsRouterStore.subtractCurrentTabRouter({ path, routeIdx: index });
if (path === route.path) {
router.push(nextRouter.path);
}
};
const handleChangeCurrentTab = (path: string) => {
router.push(path);
};
const handleRefresh = (currentPath: string, routeIdx: number) => {
tabsRouterStore.toggleTabRouterAlive(routeIdx);
nextTick(() => {
tabsRouterStore.toggleTabRouterAlive(routeIdx);
router.replace({ path: currentPath });
});
};
const handleCloseAhead = (path: string, routeIdx: number) => {
tabsRouterStore.subtractTabRouterAhead({ path, routeIdx });
};
const handleCloseBehind = (path: string, routeIdx: number) => {
tabsRouterStore.subtractTabRouterBehind({ path, routeIdx });
};
const handleCloseOther = (path: string, routeIdx: number) => {
tabsRouterStore.subtractTabRouterOther({ path, routeIdx });
};
const renderSidebar = () => {
return (
settingStore.showSidebar && (
<TDesignSideNav
<LayoutSideNav
showLogo={settingStore.showSidebarLogo}
layout={settingStore.layout}
isFixed={settingStore.isSidebarFixed}
@ -75,7 +127,7 @@ export default defineComponent({
const renderHeader = () => {
return (
settingStore.showHeader && (
<TDesignHeader
<LayoutHeader
showLogo={settingStore.showHeaderLogo}
theme={settingStore.displayMode}
layout={settingStore.layout}
@ -90,18 +142,72 @@ export default defineComponent({
const renderFooter = () => {
return (
<t-footer class={`${prefix}-footer-layout`}>
<TDesignFooter />
<LayoutFooter />
</t-footer>
);
};
const renderContent = () => {
const { showBreadcrumb, showFooter } = settingStore;
const { showBreadcrumb, showFooter, isUseTabsRouter } = settingStore;
const { tabRouters } = tabsRouterStore;
return (
<t-layout class={[`${prefix}-layout`]}>
{isUseTabsRouter && (
<t-tabs
theme="card"
class={`${prefix}-layout-tabs-nav`}
value={route.path}
onChange={handleChangeCurrentTab}
style={{ maxWidth: '100%', position: 'fixed', overflow: 'visible' }}
onRemove={handleRemove}
>
{tabRouters.map((router: any, idx: number) => (
<t-tab-panel
value={router.path}
key={`${router.path}_${idx}`}
label={
<t-dropdown
trigger="context-menu"
minColumnWidth={128}
popupProps={{ overlayClassName: 'router-tabs-dropdown' }}
v-slots={{
dropdown: () => (
<t-dropdown-menu>
<t-dropdown-item onClick={() => handleRefresh(router.path, idx)}>
<t-icon name="refresh" />
</t-dropdown-item>
{idx > 0 && (
<t-dropdown-item onClick={() => handleCloseAhead(router.path, idx)}>
<t-icon name="arrow-left" />
</t-dropdown-item>
)}
{idx < tabRouters.length - 1 && (
<t-dropdown-item onClick={() => handleCloseBehind(router.path, idx)}>
<t-icon name="arrow-right" />
</t-dropdown-item>
)}
<t-dropdown-item onClick={() => handleCloseOther(router.path, idx)}>
<t-icon name="close-circle" />
</t-dropdown-item>
</t-dropdown-menu>
),
}}
>
{!router.isHome ? router.title : <t-icon name="home" />}
</t-dropdown>
}
removable={!router.isHome}
/>
))}
</t-tabs>
)}
<t-content class={`${prefix}-content-layout`}>
{showBreadcrumb && <TDesignBreadcrumb />}
<TDesignContent />
{showBreadcrumb && <LayoutBreadcrumb />}
<LayoutContent />
</t-content>
{showFooter && renderFooter()}
</t-layout>
@ -134,7 +240,7 @@ export default defineComponent({
<t-layout class={this.mainLayoutCls}>{[sidebar, content]}</t-layout>
</t-layout>
)}
<TdesignSetting />
<Setting />
</div>
);
},

View File

@ -82,6 +82,9 @@
<t-form-item label="显示 Footer" name="showFooter">
<t-switch v-model="formData.showFooter" />
</t-form-item>
<t-form-item label="使用 多标签Tab页" name="isUseTabsRouter">
<t-switch v-model="formData.isUseTabsRouter"></t-switch>
</t-form-item>
</t-form>
<div class="setting-info">
<p>请复制后手动修改配置文件: /src/config/style.ts</p>

View File

@ -202,6 +202,13 @@
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'DashboardBase',
};
</script>
<script setup lang="ts">
import { onMounted, watch, ref, onUnmounted, nextTick, computed } from 'vue';
@ -386,9 +393,11 @@ const getRankClass = (index: number) => {
return ['dashboard-rank', { 'dashboard-rank__top': index < 3 }];
};
</script>
<style lang="less" scoped>
@import './index.less';
</style>
<style lang="less">
@import '@/style/variables.less';

View File

@ -57,6 +57,13 @@
</card>
</div>
</template>
<script lang="ts">
export default {
name: 'DashboardDetail',
};
</script>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, watch, computed } from 'vue';
@ -149,6 +156,7 @@ const onMaterialChange = (value: string[]) => {
lineChart.setOption(getFolderLineDataSet({ dateTime: value, ...chartColors.value }));
};
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -109,6 +109,13 @@
</t-dialog>
</div>
</template>
<script lang="ts">
export default {
name: 'DetailAdvanced',
};
</script>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { prefix } from '@/config/global';
@ -175,6 +182,7 @@ const onConfirm = () => {
visible.value = false;
};
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -26,6 +26,13 @@
</card>
</div>
</template>
<script lang="ts">
export default {
name: 'DetailBase',
};
</script>
<script setup lang="ts">
import Card from '@/components/card/index.vue';
@ -108,6 +115,7 @@ const BASE_INFO_DATA = [
},
];
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -68,6 +68,13 @@
</t-dialog>
</div>
</template>
<script lang="ts">
export default {
name: 'DetailDeploy',
};
</script>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
@ -195,6 +202,7 @@ const deleteClickOp = (e) => {
data.value.splice(e.rowIndex, 1);
};
</script>
<style lang="less" scoped>
@import url('../base/index.less');
</style>

View File

@ -49,6 +49,13 @@
/>
</div>
</template>
<script lang="ts">
export default {
name: 'DetailSecondary',
};
</script>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { storeToRefs } from 'pinia';
@ -114,6 +121,7 @@ const deleteMsg = () => {
store.setMsgData(changeMsg);
};
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -156,6 +156,13 @@
</div>
</t-form>
</template>
<script lang="ts">
export default {
name: 'FormBase',
};
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
@ -190,6 +197,7 @@ const formatResponse = (res) => {
return { ...res, error: '上传失败,请重试', url: res.url };
};
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -139,6 +139,13 @@
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'FormStep',
};
</script>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
@ -186,6 +193,7 @@ const complete = () => {
router.replace({ path: '/detail/advanced' });
};
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -66,6 +66,13 @@
/>
</div>
</template>
<script lang="ts">
export default {
name: 'ListBase',
};
</script>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
@ -171,6 +178,7 @@ const handleClickDelete = (row: { rowIndex: any }) => {
confirmVisible.value = true;
};
</script>
<style lang="less" scoped>
@import '@/style/variables';

View File

@ -60,6 +60,13 @@
/>
</div>
</template>
<script lang="ts">
export default {
name: 'ListCard',
};
</script>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { SearchIcon } from 'tdesign-icons-vue-next';
@ -141,6 +148,7 @@ const handleManageProduct = (product) => {
formData.value = { ...product, status: product?.isSetup ? '1' : '0' };
};
</script>
<style lang="less" scoped>
@import '@/style/variables.less';

View File

@ -1,6 +1,11 @@
<template>
<common-table />
</template>
<script lang="ts">
export default {
name: 'ListFilter',
};
</script>
<script setup lang="ts">
import CommonTable from '../components/CommonTable.vue';
</script>

View File

@ -15,6 +15,13 @@
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'ListTree',
};
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { SearchIcon } from 'tdesign-icons-vue-next';
@ -34,6 +41,7 @@ const onInput = () => {
};
};
</script>
<style lang="less" scoped>
@import '@/style/variables.less';
.table-tree-container {

View File

@ -22,6 +22,11 @@
<footer class="copyright">Copyright @ 2021-2022 Tencent. All Rights Reserved</footer>
</div>
</template>
<script lang="ts">
export default {
name: 'LoginIndex',
};
</script>
<script setup lang="ts">
import { ref } from 'vue';
@ -35,6 +40,7 @@ const switchType = (val: string) => {
type.value = val;
};
</script>
<style lang="less">
@import url('./index.less');
</style>

View File

@ -3,7 +3,11 @@
<t-button @click="() => $router.push('/')">返回首页</t-button>
</result>
</template>
<script lang="ts">
export default {
name: 'Result403',
};
</script>
<script setup lang="ts">
import Result from '@/components/result/index.vue';
</script>

View File

@ -4,6 +4,12 @@
</result>
</template>
<script lang="ts">
export default {
name: 'Result404',
};
</script>
<script setup lang="ts">
import Result from '@/components/result/index.vue';
</script>

View File

@ -3,7 +3,11 @@
<t-button @click="() => $router.push('/')">返回首页</t-button>
</result>
</template>
<script lang="ts">
export default {
name: 'Result500',
};
</script>
<script setup lang="ts">
import Result from '@/components/result/index.vue';
</script>

View File

@ -18,11 +18,16 @@
</div>
</result>
</template>
<script lang="ts">
export default {
name: 'ResultBrowserIncompatible',
};
</script>
<script setup lang="ts">
import Result from '@/components/result/index.vue';
import Thumbnail from '@/components/thumbnail/index.vue';
</script>
<style lang="less" scoped>
@import '@/style/variables.less';

View File

@ -9,7 +9,11 @@
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'ResultFail',
};
</script>
<style lang="less" scoped>
@import '@/style/variables.less';

View File

@ -7,6 +7,11 @@
</result>
</template>
<script lang="ts">
export default {
name: 'ResultNetworkError',
};
</script>
<script setup lang="ts">
import Result from '@/components/result/index.vue';
</script>

View File

@ -9,7 +9,11 @@
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'ResultSuccess',
};
</script>
<style lang="less" scoped>
@import '@/style/variables.less';

View File

@ -89,6 +89,11 @@
</t-col>
</t-row>
</template>
<script lang="ts">
export default {
name: 'UserIndex',
};
</script>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, watch, computed } from 'vue';
import * as echarts from 'echarts/core';
@ -172,6 +177,7 @@ watch(
},
);
</script>
<style lang="less" scoped>
@import url('./index.less');
</style>

View File

@ -11,13 +11,13 @@ export default [
children: [
{
path: 'base',
name: 'dashboardBase',
name: 'DashboardBase',
component: () => import('@/pages/dashboard/base/index.vue'),
meta: { title: '概览仪表盘' },
},
{
path: 'detail',
name: 'dashboardDetail',
name: 'DashboardDetail',
component: () => import('@/pages/dashboard/detail/index.vue'),
meta: { title: '统计报表' },
},

View File

@ -13,25 +13,25 @@ export default [
children: [
{
path: 'base',
name: 'listBase',
name: 'ListBase',
component: () => import('@/pages/list/base/index.vue'),
meta: { title: '基础列表页' },
},
{
path: 'card',
name: 'listCard',
name: 'ListCard',
component: () => import('@/pages/list/card/index.vue'),
meta: { title: '卡片列表页' },
},
{
path: 'filter',
name: 'listFilter',
name: 'ListFilter',
component: () => import('@/pages/list/filter/index.vue'),
meta: { title: '筛选列表页' },
},
{
path: 'tree',
name: 'listTree',
name: 'ListTree',
component: () => import('@/pages/list/tree/index.vue'),
meta: { title: '树状筛选列表页' },
},
@ -46,13 +46,13 @@ export default [
children: [
{
path: 'base',
name: 'formBase',
name: 'FormBase',
component: () => import('@/pages/form/base/index.vue'),
meta: { title: '基础表单页' },
},
{
path: 'step',
name: 'formStep',
name: 'FormStep',
component: () => import('@/pages/form/step/index.vue'),
meta: { title: '分步表单页' },
},
@ -67,25 +67,25 @@ export default [
children: [
{
path: 'base',
name: 'detailBase',
name: 'DetailBase',
component: () => import('@/pages/detail/base/index.vue'),
meta: { title: '基础详情页' },
},
{
path: 'advanced',
name: 'detailAdvanced',
name: 'DetailAdvanced',
component: () => import('@/pages/detail/advanced/index.vue'),
meta: { title: '多卡片详情页' },
},
{
path: 'deploy',
name: 'detailDeploy',
name: 'DetailDeploy',
component: () => import('@/pages/detail/deploy/index.vue'),
meta: { title: '数据详情页' },
},
{
path: 'secondary',
name: 'detailSecondary',
name: 'DetailSecondary',
component: () => import('@/pages/detail/secondary/index.vue'),
meta: { title: '二级详情页' },
},
@ -100,43 +100,43 @@ export default [
children: [
{
path: 'success',
name: 'resultSuccess',
name: 'ResultSuccess',
component: () => import('@/pages/result/success/index.vue'),
meta: { title: '成功页' },
},
{
path: 'fail',
name: 'resultFail',
name: 'ResultFail',
component: () => import('@/pages/result/fail/index.vue'),
meta: { title: '失败页' },
},
{
path: 'network-error',
name: 'warningNetworkError',
name: 'ResultNetworkError',
component: () => import('@/pages/result/network-error/index.vue'),
meta: { title: '网络异常' },
},
{
path: '403',
name: 'warning403',
name: 'Result403',
component: () => import('@/pages/result/403/index.vue'),
meta: { title: '无权限' },
},
{
path: '404',
name: 'warning404',
name: 'Result404',
component: () => import('@/pages/result/404/index.vue'),
meta: { title: '访问页面不存在页' },
},
{
path: '500',
name: 'warning500',
name: 'Result500',
component: () => import('@/pages/result/500/index.vue'),
meta: { title: '服务器出错页' },
},
{
path: 'browser-incompatible',
name: 'warningBrowserIncompatible',
name: 'ResultBrowserIncompatible',
component: () => import('@/pages/result/browser-incompatible/index.vue'),
meta: { title: '浏览器不兼容页' },
},

View File

@ -11,7 +11,7 @@ export default [
children: [
{
path: 'index',
name: 'userIndex',
name: 'UserIndex',
component: () => import('@/pages/user/index.vue'),
meta: { title: '个人中心' },
},

View File

@ -8,5 +8,6 @@ export * from './modules/notification';
export * from './modules/permission';
export * from './modules/user';
export * from './modules/setting';
export * from './modules/tabs-router';
export default store;

View File

@ -0,0 +1,51 @@
import { defineStore } from 'pinia';
import { TRouterInfo, TTabRouterType } from '@/interface';
import { store } from '@/store';
const state = {
tabRouterList: [{ path: '/dashboard/base', routeIdx: 0, title: '仪表盘', name: 'DashboardBase', isHome: true }],
isRefreshing: false,
};
export const useTabsRouterStore = defineStore('tabsRouter', {
state: () => state,
getters: {
tabRouters: (state: TTabRouterType) => state.tabRouterList,
refreshing: (state: TTabRouterType) => state.isRefreshing,
},
actions: {
toggleTabRouterAlive(routeIdx: number) {
this.isRefreshing = !this.isRefreshing;
this.tabRouters[routeIdx].isAlive = !this.tabRouterList[routeIdx].isAlive;
},
appendTabRouterList(newRoute: TRouterInfo) {
if (!this.tabRouterList.find((route: TRouterInfo) => route.path === newRoute.path)) {
// eslint-disable-next-line no-param-reassign
this.tabRouterList = this.tabRouterList.concat(newRoute);
}
},
subtractCurrentTabRouter(newRoute: TRouterInfo) {
const { routeIdx } = newRoute;
this.tabRouterList = this.tabRouterList.slice(0, routeIdx).concat(this.tabRouterList.slice(routeIdx + 1));
},
subtractTabRouterBehind(newRoute: TRouterInfo) {
const { routeIdx } = newRoute;
this.tabRouterList = this.tabRouterList.slice(0, routeIdx + 1);
},
subtractTabRouterAhead(newRoute: TRouterInfo) {
const { routeIdx } = newRoute;
this.tabRouterList = this.tabRouterList.slice(routeIdx);
},
subtractTabRouterOther(newRoute: TRouterInfo) {
const { routeIdx } = newRoute;
this.tabRouterList = [this.tabRouterList?.[routeIdx]];
},
removeTabRouterList() {
this.tabRouterList = [];
},
},
});
export function getTabsRouterStore() {
return useTabsRouterStore(store);
}

View File

@ -56,13 +56,22 @@
}
}
&-content-layout {
padding: @spacer-3;
}
&-layout{
height: calc(100vh - 64px);
overflow-y: scroll;
}
&-content-layout {
padding: @spacer-3;
&-tabs-nav {
max-width: 100%;
position: fixed;
overflow: visible;
z-index: 999;
}
&-tabs-nav + .@{prefix}-content-layout {
padding-top: 48px + @spacer-3;
}
}
&-footer-layout {
@ -151,6 +160,12 @@
}
}
.route-tabs-dropdown {
.t-icon {
margin-right: 8px;
}
}
.logo-container {
cursor: pointer;
display: inline-flex;