From 826a541a607d24ab9f60479ff81e3ab698f45655 Mon Sep 17 00:00:00 2001 From: ZhuRui Date: Mon, 27 Jul 2026 16:29:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E9=A1=B5=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/index.ts | 1 + src/components/page/PageTabs.module.less | 185 +++++++++++++++++++++++ src/components/page/PageTabs.tsx | 89 +++++++++++ src/layouts/BasicLayout.module.less | 18 +++ src/layouts/BasicLayout.tsx | 145 ++++++++++++------ src/router/guard.ts | 14 ++ src/stores/tabsStore.ts | 120 +++++++++++++++ src/types/index.ts | 4 + 8 files changed, 529 insertions(+), 47 deletions(-) create mode 100644 src/components/page/PageTabs.module.less create mode 100644 src/components/page/PageTabs.tsx create mode 100644 src/stores/tabsStore.ts diff --git a/src/components/index.ts b/src/components/index.ts index fdb9b81..1915fb5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,3 +4,4 @@ */ export { default as HelloWorld } from './HelloWorld'; export { default as ChangePasswordModal } from './ChangePasswordModal'; +export { default as PageTabs } from './page/PageTabs'; diff --git a/src/components/page/PageTabs.module.less b/src/components/page/PageTabs.module.less new file mode 100644 index 0000000..a9b2287 --- /dev/null +++ b/src/components/page/PageTabs.module.less @@ -0,0 +1,185 @@ +/** + * PageTabs — Chrome 风格标签栏样式 + * + * 参考 VBEN tabs-chrome 的视觉设计: + * - 标签间通过负 margin 重叠 + * - 激活态使用 SVG 绘制左下/右下曲线填充,形成 Chrome tab 效果 + * - hover 态浅灰背景 + * - 分隔线在相邻非激活标签之间 + */ + +// 主色调(antd 默认蓝) +@primary: #1677ff; + +.tabsWrap { + display: flex; + align-items: center; + width: 100%; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + // 隐藏横向滚动条 + &::-webkit-scrollbar { + height: 0; + } +} + +.tabItem { + position: relative; + display: flex; + align-items: center; + height: 100%; + user-select: none; + transition: all 0.15s ease; + padding: 0 8px 0 14px; + + &:global(:not(.dragging)) { + cursor: pointer; + } + + // ── hover 态(非激活) ── + &:not(.active) { + &:hover { + // 自身分隔线消失 + > .divider { + opacity: 0; + } + // 右侧邻居的分隔线也消失 + & + .tabItem { + > .divider { + opacity: 0; + } + } + } + } + + // ── 激活态 ── + &.active { + z-index: 2; + // 右侧邻居的分隔线消失 + & + .tabItem { + > .divider { + opacity: 0 !important; + } + } + // 文字激活色 + .tabTitle { + color: @primary; + font-weight: 500; + } + // 背景蓝色 + .tabBg { + &.tabBgActive { + .tabBgInner { + background-color: fade(@primary, 8%); + } + .tabBgCurveBefore, + .tabBgCurveAfter { + fill: fade(@primary, 8%); + } + } + } + } +} + +// ── 分隔线 ── +.divider { + position: absolute; + left: 7px; + top: 50%; + z-index: 0; + width: 1px; + height: 16px; + background-color: #e8e8e8; + transform: translateY(-50%); + transition: opacity 0.15s; +} + +// ── Chrome 风格背景层(激活态可见,hover 态可见但偏浅) ── +.tabBg { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + padding: 0 calc(7px - 1px); + transition: opacity 0.15s; +} + +// 背景主体:顶部圆角 +.tabBgInner { + height: 100%; + border-radius: 7px 7px 0 0; + transition: + background-color 0.15s, + margin 0.15s, + border-radius 0.15s; +} + +// 左下 SVG 曲线 +.tabBgCurveBefore { + position: absolute; + left: -1px; + bottom: 0; + fill: transparent; + transition: fill 0.15s; +} + +// 右下 SVG 曲线 +.tabBgCurveAfter { + position: absolute; + right: -1px; + bottom: 0; + fill: transparent; + transition: fill 0.15s; +} + +// ── 关闭按钮(flex 子元素,文字同行自然居中) ── +.tabClose { + flex-shrink: 0; + width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + text-align: center; + margin-right: 12px; + color: rgba(0, 0, 0, 0.45); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s; + + &:hover { + color: rgba(0, 0, 0, 0.85); + background-color: rgba(0, 0, 0, 0.06); + } +} + +.tabTitleCon { + height: 28px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; +} + +// hover 只在非激活标签时生效 +.tabItem:not(.active) { + .tabTitleCon:hover { + background-color: rgba(0, 0, 0, 0.06); + } +} + +// ── 标签文字(flex 子元素,可收缩) ── +.tabTitle { + position: relative; + z-index: 2; + flex: 1; + min-width: 0; + padding: 2px 8px; + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/page/PageTabs.tsx b/src/components/page/PageTabs.tsx new file mode 100644 index 0000000..35c8248 --- /dev/null +++ b/src/components/page/PageTabs.tsx @@ -0,0 +1,89 @@ +import { defineComponent, type PropType } from 'vue'; +import { message } from 'ant-design-vue'; +import type { TabView } from '@/stores/tabsStore'; +import styles from './PageTabs.module.less'; + +/** + * Chrome 风格的页面标签栏组件 + * 参考 VBEN tabs-chrome 的视觉设计: + * - 标签重叠排列(负 margin) + * - 激活态:圆角背景 + SVG 底部曲线 + * - hover 态:浅灰背景 + * - 标签间分隔线 + * - 关闭按钮 + */ +export default defineComponent({ + name: 'PageTabs', + props: { + /** 标签列表 */ + tabs: { type: Array as PropType, required: true }, + /** 当前激活 key */ + activeKey: { type: String, required: true }, + /** 关闭标签 */ + onClose: { type: Function as PropType<(key: string) => void>, default: undefined }, + /** 切换标签 */ + onChange: { type: Function as PropType<(key: string) => void>, default: undefined }, + }, + setup(props) { + const handleClick = (key: string) => { + if (key !== props.activeKey && props.onChange) { + props.onChange(key); + } + }; + + const handleClose = (key: string, e: Event) => { + e.stopPropagation(); + if (props.tabs.length <= 1) { + message.warning('至少保留一个标签'); + return; + } + if (props.onClose) { + props.onClose(key); + } + }; + + return () => ( +
+ {props.tabs.map((tab, i) => { + const isActive = tab.path === props.activeKey; + return ( +
handleClick(tab.path)} + > + {/* 分隔线:第一个标签 / 激活标签 / 上一个就是激活的 → 不显示 */} +
+ + {/* Chrome 风格背景:激活态 */} +
+
+ {/* 左下曲线 */} + + + + {/* 右下曲线 */} + + + +
+ +
+ {/* 标签文字(flex 子元素,与关闭按钮同行) */} + {tab.title} +
+ + {/* 关闭按钮(flex 子元素,不参与 shrink) */} + handleClose(tab.path, e)}> + ✕ + +
+ ); + })} +
+ ); + }, +}); diff --git a/src/layouts/BasicLayout.module.less b/src/layouts/BasicLayout.module.less index 3b182a3..02d1ac5 100644 --- a/src/layouts/BasicLayout.module.less +++ b/src/layouts/BasicLayout.module.less @@ -113,3 +113,21 @@ display: flex; flex-direction: column; } + +// ── 顶部行:原 Header 中的三个区域(折叠按钮 / 面包屑 / 右侧账号信息)整体包成一个 row ── +.headerRow { + padding: 0 16px; + width: 100%; + height: 48px; + display: flex; + align-items: center; +} + +// ── 页面标签栏容器(Chrome 风格 PageTabs 的外壳) ── +.pageTabs { + width: 100%; + padding: 4px 16px 0 16px; + height: 42px; + background: #fff; + border-top: 1px solid #f0f0f0; +} diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index cea6533..dbbddf3 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -1,5 +1,6 @@ import { computed, defineComponent, h, ref } from 'vue'; import type { CSSProperties } from 'vue'; +import { KeepAlive } from 'vue'; import { useRouter, useRoute, RouterView } from 'vue-router'; import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue'; import type { MenuProps } from 'ant-design-vue'; @@ -16,8 +17,9 @@ import { } from '@ant-design/icons-vue'; import { useMenuStore } from '@/stores/menuStore'; import { useUserStore } from '@/stores/userStore'; +import { useTabsStore } from '@/stores/tabsStore'; import { useState, useBreadcrumb, auth } from '@/hooks'; -import { ChangePasswordModal } from '@/components'; +import { ChangePasswordModal, PageTabs } from '@/components'; import logoutSvg from '@/assets/img/icon/logout.svg'; import styles from './BasicLayout.module.less'; @@ -25,11 +27,13 @@ const { Header, Sider, Content, Footer } = Layout; const headerStyle: CSSProperties = { background: '#fff', - padding: '0 16px', + padding: '0', display: 'flex', - alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', color: '#1a1a1a', boxShadow: '0 1px 4px rgba(0, 21, 41, 0.08)', + height: 'auto', }; const siderStyle: CSSProperties = { @@ -93,6 +97,7 @@ export default defineComponent({ const { menuItems } = useMenuStore(); const { phone } = useUserStore(); + const { tabs, cachedViews, removeTab } = useTabsStore(); const { breadcrumbs } = useBreadcrumb(); const selectedKeys = computed(() => [route.path]); @@ -107,6 +112,8 @@ export default defineComponent({ collapsed.value = !collapsed.value; }; + const activeTabKey = computed(() => route.path); + /** ===== 退出登录 ===== */ const handleLogout = () => { Modal.confirm({ @@ -154,6 +161,17 @@ export default defineComponent({ } }; + /** ===== PageTabs 事件 ===== */ + const handleTabChange = (key: string) => { + if (key !== route.path) { + router.push(key); + } + }; + + const handleTabClose = (key: string) => { + removeTab(key); + }; + return () => ( @@ -171,56 +189,89 @@ export default defineComponent({
- - {collapsed.value ? : } - + {/* + ── 顶部行:折叠按钮 + 面包屑 + 右侧账号信息 ── + 按需求把原来平铺在 Header 里的三块统一包到一个 row 容器内, + 形成「行容器 + tabs 容器」的两层结构 + */} +
+ + {collapsed.value ? : } + - {/* 面包屑:紧跟在折叠按钮之后 */} - - {breadcrumbs.value.map((item, index) => { - const isLast = index === breadcrumbs.value.length - 1; - return ( - - {isLast || !item.path ? ( - {item.title} - ) : ( - router.push(item.path as string)}>{item.title} - )} - - ); - })} - + {/* 面包屑:紧跟在折叠按钮之后 */} + + {breadcrumbs.value.map((item, index) => { + const isLast = index === breadcrumbs.value.length - 1; + return ( + + {isLast || !item.path ? ( + {item.title} + ) : ( + router.push(item.path as string)}>{item.title} + )} + + ); + })} + - {/* 右侧: 账号信息 + 退出 */} -
- {phone.value} - - {{ - default: () => , - overlay: () => ( - - - - - 修改密码 - - - - - - - 退出登录 - - - - ), - }} - + {/* 右侧: 账号信息 + 退出 */} +
+ {phone.value} + + {{ + default: () => , + overlay: () => ( + + + + + 修改密码 + + + + + + + 退出登录 + + + + ), + }} + +
+
+ + {/* + ── Tabs 行:Chrome 风格页面标签栏 ── + 数据来自 tabsStore,点击切换路由,关闭按钮走 store.removeTab + */} +
+
+ {/* + ── 路由出口 ── + 用 缓存已访问过的页面, + include 是组件 name 列表,所以页面组件需要在 defineComponent 中显式给出 name + */} - + ( + + + + ), + }} + />
六个羽友赛事运营平台 ©{new Date().getFullYear()}
diff --git a/src/router/guard.ts b/src/router/guard.ts index b2beeae..d62648e 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -1,5 +1,6 @@ import router from '@/router'; import { auth } from '@/hooks/useAuth'; +import { useTabsStore } from '@/stores/tabsStore'; const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台'; @@ -57,4 +58,17 @@ router.beforeEach(async (to, _from, next) => { router.afterEach((to) => { updateDocumentTitle(to.meta.title as string | undefined); + + // ── 路由跳转 → tab 来源 ── + // 隐藏的路由(如详情页、编辑页)不进 tab,避免标签栏被噪声污染 + // /login 也不进 tab + if (to.meta?.hideInMenu || to.path === '/login') return; + if (typeof to.name !== 'string' || typeof to.meta?.title !== 'string') return; + + const { addTab } = useTabsStore(); + addTab({ + path: to.path, + name: to.name, + title: to.meta.title, + }); }); diff --git a/src/stores/tabsStore.ts b/src/stores/tabsStore.ts new file mode 100644 index 0000000..9812897 --- /dev/null +++ b/src/stores/tabsStore.ts @@ -0,0 +1,120 @@ +import { reactive, computed, toRefs } from 'vue'; +import router from '@/router'; + +/** + * 单个页面标签的数据结构(标签 = 已经访问过的页面) + * + * path 唯一标识,用于路由激活态判断 + * name 路由组件 name,用于 + * title 展示在 tab 上的文字 + */ +export interface TabView { + path: string; + name: string; + title: string; +} + +interface TabsState { + /** 已打开的页面标签列表,按访问顺序累加 */ + tabs: TabView[]; + /** 需要被 keep-alive 缓存的组件 name 列表(来自每个 tab 的 name) */ + cachedViews: string[]; +} + +const state = reactive({ + tabs: [], + cachedViews: [], +}); + +/** + * 全局唯一 store 的 composable 入口 + * 参考其他 store (menuStore / permissionStore) 的写法,保持代码风格统一 + */ +export function useTabsStore() { + /** + * 往 tabs 列表加入一个 tab。 + * - 已存在则忽略(根据 path 去重) + * - 同步把 name 加入 cachedViews,确保 能缓存该组件 + * + * 通常由 router.afterEach 主动调用,所以 nav 来源 = 路由跳转。 + */ + const addTab = (view: TabView): void => { + if (!view || !view.path || !view.name) return; + + if (!state.tabs.some((t) => t.path === view.path)) { + state.tabs.push(view); + } + if (!state.cachedViews.includes(view.name)) { + state.cachedViews.push(view.name); + } + }; + + /** + * 主动(程序化地)关闭一个 tab + * - 只剩一个标签时不允许关闭(至少保留一个) + * - 关闭当前激活的 tab 时,自动跳到相邻 tab + * - 同步从 cachedViews 里移除(关闭后下次再访问会重新挂载) + */ + const removeTab = (path: string): void => { + if (state.tabs.length <= 1) return; + + const idx = state.tabs.findIndex((t) => t.path === path); + if (idx === -1) return; + + const target = state.tabs[idx]; + const isActive = router.currentRoute.value.path === path; + const next = state.tabs[idx + 1] || state.tabs[idx - 1]; + + state.tabs.splice(idx, 1); + + const cachedIdx = state.cachedViews.indexOf(target.name); + if (cachedIdx !== -1) state.cachedViews.splice(cachedIdx, 1); + + if (isActive && next) { + router.push(next.path); + } + }; + + /** 关闭除 path 之外的所有 tab */ + const removeOtherTabs = (path: string): void => { + state.tabs = state.tabs.filter((t) => t.path === path); + syncCachedViews(); + }; + + /** 关闭所有 tab,保留第一个并跳转过去 */ + const removeAllTabs = (): void => { + state.tabs = state.tabs.slice(0, 1); + syncCachedViews(); + const first = state.tabs[0]; + if (first) router.push(first.path); + }; + + /** 更新某个 tab 的字段(比如标题改名),按 path 定位 */ + const updateTab = (path: string, partial: Partial): void => { + const target = state.tabs.find((t) => t.path === path); + if (target) Object.assign(target, partial); + }; + + /** 全部清空——退出登录时调用 */ + const clearTabs = (): void => { + state.tabs = []; + state.cachedViews = []; + }; + + /** 把 cachedViews 同步成「tabs 中仍存在的 name」 */ + function syncCachedViews() { + state.cachedViews = state.cachedViews.filter((name) => state.tabs.some((t) => t.name === name)); + } + + return { + ...toRefs(state), + tabs: computed(() => state.tabs), + cachedViews: computed(() => state.cachedViews), + addTab, + removeTab, + removeOtherTabs, + removeAllTabs, + updateTab, + clearTabs, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 2140445..74b4422 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,5 +97,9 @@ declare module 'vue-router' { permission?: string; /** 外链地址 */ externalLink?: string; + /** 是否固定标签(不可关闭,常用于首页 dashboard) */ + affix?: boolean; + /** 不参与标签缓存,即使访问过也不进 keep-alive */ + noCache?: boolean; } }