Compare commits
2 Commits
202f4bbd44
...
a4dab891cf
| Author | SHA1 | Date | |
|---|---|---|---|
| a4dab891cf | |||
| da90d7c356 |
@@ -27,6 +27,7 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
|
"vue-icons-plus": "^0.1.8",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+12
@@ -38,6 +38,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.5.40(typescript@5.9.3)
|
version: 3.5.40(typescript@5.9.3)
|
||||||
|
vue-icons-plus:
|
||||||
|
specifier: ^0.1.8
|
||||||
|
version: 0.1.8(vue@3.5.40(typescript@5.9.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.6.4(vue@3.5.40(typescript@5.9.3))
|
version: 4.6.4(vue@3.5.40(typescript@5.9.3))
|
||||||
@@ -2188,6 +2191,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=6.0.0'
|
eslint: '>=6.0.0'
|
||||||
|
|
||||||
|
vue-icons-plus@0.1.8:
|
||||||
|
resolution: {integrity: sha512-Xc4hDsD/oP9waSUf44nSaFBhUPo+QkpKclx0S7//5BRACpXymctbit02epek0VRW6nb81pR486XmxPP/ofm2yQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>=2.7.0'
|
||||||
|
|
||||||
vue-router@4.6.4:
|
vue-router@4.6.4:
|
||||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4404,6 +4412,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
vue-icons-plus@0.1.8(vue@3.5.40(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.40(typescript@5.9.3)
|
||||||
|
|
||||||
vue-router@4.6.4(vue@3.5.40(typescript@5.9.3)):
|
vue-router@4.6.4(vue@3.5.40(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
|
|||||||
@@ -52,6 +52,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 表格内可点击数字链接(等宽数字,避免列宽抖动)
|
||||||
|
.tabularLink {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 汇总 ──
|
// ── 汇总 ──
|
||||||
.summary {
|
.summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -94,32 +100,24 @@
|
|||||||
.confirmContent {
|
.confirmContent {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
color: rgba(0, 0, 0, 0.85);
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
color: rgba(0, 0, 0, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmTip {
|
.confirmTip {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
.tabularLink {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.toggleConfirmModal) {
|
|
||||||
.ant-modal-confirm-body {
|
|
||||||
padding: 24px !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.toggleConfirmModal),
|
||||||
:global(.deleteConfirmModal) {
|
:global(.deleteConfirmModal) {
|
||||||
.ant-modal-confirm-body {
|
.ant-modal-confirm-body {
|
||||||
padding: 24px !important;
|
padding: 24px !important;
|
||||||
|
|||||||
@@ -1,27 +1,74 @@
|
|||||||
@primary: #1677ff;
|
@primary: #1677ff;
|
||||||
|
|
||||||
.pageTabsMain {
|
.pageTabsWrap {
|
||||||
display: flex;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 5;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
box-shadow: 0 0 4px rgba(214, 214, 214, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background: #ebebeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtnLeft {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtnRight {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
height: 0;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabItem {
|
.tabItem {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
margin-left: -9px;
|
margin-left: -10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:global(:not(.dragging)) {
|
&:global(:not(.dragging)) {
|
||||||
@@ -64,6 +111,10 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabIcon {
|
||||||
|
color: @primary;
|
||||||
|
}
|
||||||
|
|
||||||
.tabBg {
|
.tabBg {
|
||||||
&.tabBgActive {
|
&.tabBgActive {
|
||||||
.tabBgInner {
|
.tabBgInner {
|
||||||
@@ -81,11 +132,11 @@
|
|||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 7px;
|
left: 8px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
background-color: #e8e8e8;
|
background-color: #e8e8e8;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
@@ -98,13 +149,13 @@
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0 calc(7px - 1px);
|
padding: 0 calc(8px - 1px);
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabBgInner {
|
.tabBgInner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 7px 7px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
transition:
|
transition:
|
||||||
background-color 0.15s,
|
background-color 0.15s,
|
||||||
margin 0.15s,
|
margin 0.15s,
|
||||||
@@ -127,25 +178,35 @@
|
|||||||
transition: fill 0.15s;
|
transition: fill 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅包裹文字 + 关闭按钮,通过 margin 收在 tab 内部
|
|
||||||
.tabContent {
|
.tabContent {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 28px;
|
height: 34px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
flex-shrink: 0;
|
||||||
margin: 0 10px 0 10px;
|
margin: 0 12px;
|
||||||
padding: 2px 2px 2px 8px;
|
padding: 4px 4px 4px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 9px;
|
||||||
transition: background-color 0.35s;
|
transition: background-color 0.35s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.tabTitle {
|
.tabTitle {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13px;
|
max-width: 160px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
color: rgba(0, 0, 0, 0.65);
|
color: rgba(0, 0, 0, 0.65);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -154,10 +215,10 @@
|
|||||||
|
|
||||||
.tabClose {
|
.tabClose {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
line-height: 16px;
|
line-height: 18px;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(0, 0, 0, 0.45);
|
color: rgba(0, 0, 0, 0.45);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -165,3 +226,4 @@
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { defineComponent, type PropType } from 'vue';
|
import {
|
||||||
|
defineComponent,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
type PropType,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
|
||||||
import type { TabView } from '@/types';
|
import type { TabView } from '@/types';
|
||||||
|
import { renderRouteIcon } from '@/utils/routeIcon';
|
||||||
import styles from './PageTabs.module.less';
|
import styles from './PageTabs.module.less';
|
||||||
|
|
||||||
|
const SCROLL_STEP = 240;
|
||||||
|
|
||||||
|
type TabKeyHandler = (key: string) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chrome 风格的页面标签栏组件
|
* Chrome 风格的页面标签栏组件
|
||||||
* 参考 VBEN tabs-chrome 的视觉设计:
|
* - 无滚动条,滚轮横向滚动
|
||||||
* - 标签重叠排列(负 margin)
|
* - 溢出时显示左右导航按钮
|
||||||
* - 激活态:圆角背景 + SVG 底部曲线
|
|
||||||
* - hover 态:浅灰背景
|
|
||||||
* - 标签间分隔线
|
|
||||||
* - 关闭按钮
|
|
||||||
*/
|
*/
|
||||||
const PageTabs = defineComponent({
|
const PageTabs = defineComponent({
|
||||||
name: 'PageTabs',
|
name: 'PageTabs',
|
||||||
@@ -20,11 +30,73 @@ const PageTabs = defineComponent({
|
|||||||
/** 当前激活 key */
|
/** 当前激活 key */
|
||||||
activeKey: { type: String, required: true },
|
activeKey: { type: String, required: true },
|
||||||
/** 关闭标签 */
|
/** 关闭标签 */
|
||||||
onClose: { type: Function as PropType<(key: string) => void>, default: undefined },
|
onClose: { type: Function as PropType<TabKeyHandler>, default: undefined },
|
||||||
/** 切换标签 */
|
/** 切换标签 */
|
||||||
onChange: { type: Function as PropType<(key: string) => void>, default: undefined },
|
onChange: { type: Function as PropType<TabKeyHandler>, default: undefined },
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
|
const scrollRef = ref<HTMLElement>();
|
||||||
|
const tabItemRefs = ref<Record<string, HTMLElement | undefined>>({});
|
||||||
|
const showNavLeft = ref(false);
|
||||||
|
const showNavRight = ref(false);
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const updateScrollState = () => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (!el) {
|
||||||
|
showNavLeft.value = false;
|
||||||
|
showNavRight.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||||
|
const canScroll = scrollWidth - clientWidth > 1;
|
||||||
|
|
||||||
|
showNavLeft.value = canScroll && scrollLeft > 1;
|
||||||
|
showNavRight.value = canScroll && scrollLeft + clientWidth < scrollWidth - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollByStep = (direction: -1 | 1) => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: direction * SCROLL_STEP, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (!el || el.scrollWidth <= el.clientWidth) return;
|
||||||
|
|
||||||
|
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||||
|
if (delta === 0) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollLeft += delta;
|
||||||
|
updateScrollState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollActiveTabIntoView = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
tabItemRefs.value[props.activeKey]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
});
|
||||||
|
updateScrollState();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.activeKey, scrollActiveTabIntoView);
|
||||||
|
watch(() => props.tabs.map((t) => t.path).join(','), scrollActiveTabIntoView);
|
||||||
|
|
||||||
|
const setTabItemRef = (path: string, el: Element | null) => {
|
||||||
|
if (el) {
|
||||||
|
tabItemRefs.value[path] = el as HTMLElement;
|
||||||
|
} else {
|
||||||
|
delete tabItemRefs.value[path];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = (key: string) => {
|
const handleClick = (key: string) => {
|
||||||
if (key !== props.activeKey && props.onChange) {
|
if (key !== props.activeKey && props.onChange) {
|
||||||
props.onChange(key);
|
props.onChange(key);
|
||||||
@@ -42,37 +114,55 @@ const PageTabs = defineComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => (
|
onMounted(() => {
|
||||||
<div class={styles.pageTabsMain}>
|
const el = scrollRef.value;
|
||||||
{props.tabs.map((tab, i) => {
|
if (!el) return;
|
||||||
|
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(updateScrollState);
|
||||||
|
resizeObserver.observe(el);
|
||||||
|
updateScrollState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const el = scrollRef.value;
|
||||||
|
if (el) {
|
||||||
|
el.removeEventListener('wheel', handleWheel);
|
||||||
|
el.removeEventListener('scroll', updateScrollState);
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderTab = (tab: TabView, i: number) => {
|
||||||
const isActive = tab.path === props.activeKey;
|
const isActive = tab.path === props.activeKey;
|
||||||
|
const showDivider = i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.path}
|
key={tab.path}
|
||||||
|
ref={(el) => setTabItemRef(tab.path, el as Element | null)}
|
||||||
class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')}
|
class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')}
|
||||||
onClick={() => handleClick(tab.path)}
|
onClick={() => handleClick(tab.path)}
|
||||||
>
|
>
|
||||||
{/* 分隔线:第一个标签 / 激活标签 / 上一个就是激活的 → 不显示 */}
|
<div v-show={showDivider} class={styles.divider} />
|
||||||
<div
|
|
||||||
v-show={i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey}
|
|
||||||
class={styles.divider}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Chrome 风格背景:激活态 */}
|
|
||||||
<div class={[styles.tabBg, isActive && styles.tabBgActive].filter(Boolean).join(' ')}>
|
<div class={[styles.tabBg, isActive && styles.tabBgActive].filter(Boolean).join(' ')}>
|
||||||
<div class={styles.tabBgInner} />
|
<div class={styles.tabBgInner} />
|
||||||
{/* 左下曲线 */}
|
<svg class={styles.tabBgCurveBefore} height="8" width="8">
|
||||||
<svg class={styles.tabBgCurveBefore} height="7" width="7">
|
<path d="M 0 8 A 8 8 0 0 0 8 0 L 8 8 Z" />
|
||||||
<path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
{/* 右下曲线 */}
|
<svg class={styles.tabBgCurveAfter} height="8" width="8">
|
||||||
<svg class={styles.tabBgCurveAfter} height="7" width="7">
|
<path d="M 0 0 A 8 8 0 0 0 8 8 L 0 8 Z" />
|
||||||
<path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标签内容:hover 背景包裹文字 + 关闭按钮 */}
|
|
||||||
<div class={styles.tabContent}>
|
<div class={styles.tabContent}>
|
||||||
|
{tab.icon ? (
|
||||||
|
<span class={styles.tabIcon}>{renderRouteIcon(tab.icon, { size: 16 })}</span>
|
||||||
|
) : null}
|
||||||
<div class={styles.tabTitle}>{tab.title}</div>
|
<div class={styles.tabTitle}>{tab.title}</div>
|
||||||
<div class={styles.tabClose} onClick={(e: Event) => handleClose(tab.path, e)}>
|
<div class={styles.tabClose} onClick={(e: Event) => handleClose(tab.path, e)}>
|
||||||
✕
|
✕
|
||||||
@@ -80,7 +170,35 @@ const PageTabs = defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class={styles.pageTabsWrap}>
|
||||||
|
{showNavLeft.value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={[styles.navBtn, styles.navBtnLeft].join(' ')}
|
||||||
|
aria-label="向左滚动标签"
|
||||||
|
onClick={() => scrollByStep(-1)}
|
||||||
|
>
|
||||||
|
<LeftOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} class={styles.pageTabs}>
|
||||||
|
{props.tabs.map((tab, i) => renderTab(tab, i))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNavRight.value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={[styles.navBtn, styles.navBtnRight].join(' ')}
|
||||||
|
aria-label="向右滚动标签"
|
||||||
|
onClick={() => scrollByStep(1)}
|
||||||
|
>
|
||||||
|
<RightOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ import type { MenuNode } from '@/types';
|
|||||||
*
|
*
|
||||||
* ```json
|
* ```json
|
||||||
* [
|
* [
|
||||||
* { "id": "dashboard", "name": "工作台", "path": "dashboard", "component": "dashboard", "icon": "DashboardOutlined" },
|
* { "id": "dashboard", "name": "工作台", "path": "dashboard", "component": "dashboard", "icon": "AiOutlineDashboard" },
|
||||||
* {
|
* {
|
||||||
* "id": "events", "name": "赛事管理", "path": "events", "icon": "TrophyOutlined",
|
* "id": "events", "name": "赛事管理", "path": "events", "icon": "AiOutlineTrophy",
|
||||||
* "children": [
|
* "children": [
|
||||||
* { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list" },
|
* { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list", "icon": "AiOutlineUnorderedList" }
|
||||||
* { "id": "events_orders", "name": "订单管理", "path": "events/orders", "component": "events/orders" }
|
|
||||||
* ]
|
* ]
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
@@ -29,7 +28,7 @@ import type { MenuNode } from '@/types';
|
|||||||
* | name | string | 是 | 菜单名称/路由 name |
|
* | name | string | 是 | 菜单名称/路由 name |
|
||||||
* | path | string | 是 | 路由相对路径(不带 / 前缀) |
|
* | path | string | 是 | 路由相对路径(不带 / 前缀) |
|
||||||
* | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") |
|
* | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") |
|
||||||
* | icon | string | 否 | antd 图标名(如 DashboardOutlined) |
|
* | icon | string | 否 | vue-icons-plus 图标名(如 AiOutlineTrophy,需在 routeIcon.tsx 注册) |
|
||||||
* | sort | number | 否 | 排序权重(越小越前) |
|
* | sort | number | 否 | 排序权重(越小越前) |
|
||||||
* | hideInMenu | boolean | 否 | 是否在菜单中隐藏 |
|
* | hideInMenu | boolean | 否 | 是否在菜单中隐藏 |
|
||||||
* | externalLink | string | 否 | 外链地址 |
|
* | externalLink | string | 否 | 外链地址 |
|
||||||
@@ -47,7 +46,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
id: 'events',
|
id: 'events',
|
||||||
name: '赛事管理',
|
name: '赛事管理',
|
||||||
path: 'events',
|
path: 'events',
|
||||||
icon: 'TrophyOutlined',
|
icon: 'AiOutlineTrophy',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'events_list',
|
id: 'events_list',
|
||||||
@@ -55,6 +54,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/list',
|
path: 'events/list',
|
||||||
component: 'events/list',
|
component: 'events/list',
|
||||||
componentName: 'EventList',
|
componentName: 'EventList',
|
||||||
|
icon: 'AiOutlineUnorderedList',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_orders',
|
id: 'events_orders',
|
||||||
@@ -62,6 +62,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/orders',
|
path: 'events/orders',
|
||||||
component: 'events/orders',
|
component: 'events/orders',
|
||||||
componentName: 'EventOrders',
|
componentName: 'EventOrders',
|
||||||
|
icon: 'AiOutlineShoppingCart',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_users',
|
id: 'events_users',
|
||||||
@@ -69,6 +70,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/users',
|
path: 'events/users',
|
||||||
component: 'events/users',
|
component: 'events/users',
|
||||||
componentName: 'EventUsers',
|
componentName: 'EventUsers',
|
||||||
|
icon: 'AiOutlineTeam',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_banner',
|
id: 'events_banner',
|
||||||
@@ -76,6 +78,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/banner',
|
path: 'events/banner',
|
||||||
component: 'events/banner',
|
component: 'events/banner',
|
||||||
componentName: 'EventBanner',
|
componentName: 'EventBanner',
|
||||||
|
icon: 'AiOutlinePicture',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events_logs',
|
id: 'events_logs',
|
||||||
@@ -83,6 +86,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'events/logs',
|
path: 'events/logs',
|
||||||
component: 'events/logs',
|
component: 'events/logs',
|
||||||
componentName: 'EventLogs',
|
componentName: 'EventLogs',
|
||||||
|
icon: 'AiOutlineHistory',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -90,7 +94,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
id: 'finance',
|
id: 'finance',
|
||||||
name: '账务管理',
|
name: '账务管理',
|
||||||
path: 'finance',
|
path: 'finance',
|
||||||
icon: 'TransactionOutlined',
|
icon: 'AiOutlineTransaction',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'finance_withdraw',
|
id: 'finance_withdraw',
|
||||||
@@ -98,6 +102,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/withdraw',
|
path: 'finance/withdraw',
|
||||||
component: 'finance/withdraw',
|
component: 'finance/withdraw',
|
||||||
componentName: 'FinanceWithdraw',
|
componentName: 'FinanceWithdraw',
|
||||||
|
icon: 'AiOutlineMoneyCollect',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finance_wallet',
|
id: 'finance_wallet',
|
||||||
@@ -105,6 +110,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/wallet',
|
path: 'finance/wallet',
|
||||||
component: 'finance/wallet',
|
component: 'finance/wallet',
|
||||||
componentName: 'FinanceWallet',
|
componentName: 'FinanceWallet',
|
||||||
|
icon: 'AiOutlineWallet',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finance_payments',
|
id: 'finance_payments',
|
||||||
@@ -112,6 +118,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/payments',
|
path: 'finance/payments',
|
||||||
component: 'finance/payments',
|
component: 'finance/payments',
|
||||||
componentName: 'FinancePayments',
|
componentName: 'FinancePayments',
|
||||||
|
icon: 'AiOutlinePayCircle',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finance_reports',
|
id: 'finance_reports',
|
||||||
@@ -119,6 +126,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'finance/reports',
|
path: 'finance/reports',
|
||||||
component: 'finance/reports',
|
component: 'finance/reports',
|
||||||
componentName: 'FinanceReports',
|
componentName: 'FinanceReports',
|
||||||
|
icon: 'AiOutlineBarChart',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -126,7 +134,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
id: 'system',
|
id: 'system',
|
||||||
name: '系统管理',
|
name: '系统管理',
|
||||||
path: 'system',
|
path: 'system',
|
||||||
icon: 'SettingOutlined',
|
icon: 'AiOutlineSetting',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'system_users',
|
id: 'system_users',
|
||||||
@@ -134,6 +142,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'system/users',
|
path: 'system/users',
|
||||||
component: 'system/users',
|
component: 'system/users',
|
||||||
componentName: 'SystemUsers',
|
componentName: 'SystemUsers',
|
||||||
|
icon: 'AiOutlineUser',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'system_roles',
|
id: 'system_roles',
|
||||||
@@ -141,6 +150,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'system/roles',
|
path: 'system/roles',
|
||||||
component: 'system/roles',
|
component: 'system/roles',
|
||||||
componentName: 'SystemRoles',
|
componentName: 'SystemRoles',
|
||||||
|
icon: 'AiOutlineSafetyCertificate',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'system_logs',
|
id: 'system_logs',
|
||||||
@@ -148,6 +158,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
path: 'system/logs',
|
path: 'system/logs',
|
||||||
component: 'system/logs',
|
component: 'system/logs',
|
||||||
componentName: 'SystemLogs',
|
componentName: 'SystemLogs',
|
||||||
|
icon: 'AiOutlineAudit',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,8 +129,8 @@
|
|||||||
|
|
||||||
.pageTabs {
|
.pageTabs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 16px 0 16px;
|
height: 48px;
|
||||||
height: 42px;
|
padding: 4px 8px 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,17 @@ import { useRouter, useRoute, RouterView } from 'vue-router';
|
|||||||
import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue';
|
import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue';
|
||||||
import type { MenuProps } from 'ant-design-vue';
|
import type { MenuProps } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
InfoCircleOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
SettingOutlined,
|
|
||||||
TransactionOutlined,
|
|
||||||
TrophyOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { useMenuStore } from '@/stores/menuStore';
|
import { useMenuStore } from '@/stores/menuStore';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import { useTabsStore } from '@/stores/tabsStore';
|
import { useTabsStore } from '@/stores/tabsStore';
|
||||||
import { useState, useBreadcrumb, auth } from '@/hooks';
|
import { useState, useBreadcrumb, auth } from '@/hooks';
|
||||||
import { ChangePasswordModal, PageTabs } from '@/components';
|
import { ChangePasswordModal, PageTabs } from '@/components';
|
||||||
|
import { renderRouteIcon } from '@/utils/routeIcon';
|
||||||
import logoutSvg from '@/assets/img/icon/logout.svg';
|
import logoutSvg from '@/assets/img/icon/logout.svg';
|
||||||
import styles from './BasicLayout.module.less';
|
import styles from './BasicLayout.module.less';
|
||||||
|
|
||||||
@@ -47,22 +43,9 @@ const footerStyle: CSSProperties = {
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 图标名 → 组件映射
|
|
||||||
* 后端下发的 icon 字符串在此映射为 antd icon 组件
|
|
||||||
* 扩展新图标只需在此添加一行
|
|
||||||
*/
|
|
||||||
const ICON_MAP: Record<string, any> = {
|
|
||||||
DashboardOutlined,
|
|
||||||
InfoCircleOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
TransactionOutlined,
|
|
||||||
TrophyOutlined,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
* 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式
|
||||||
* 递归处理 iconName → icon VNode
|
* 递归处理 iconName → vue-icons-plus 图标 VNode
|
||||||
*/
|
*/
|
||||||
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
function resolveMenuItems(items: any[]): MenuProps['items'] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
@@ -71,9 +54,8 @@ function resolveMenuItems(items: any[]): MenuProps['items'] {
|
|||||||
label: item.label,
|
label: item.label,
|
||||||
};
|
};
|
||||||
|
|
||||||
// iconName 字符串转为 VNode
|
if (item.iconName) {
|
||||||
if (item.iconName && ICON_MAP[item.iconName]) {
|
resolved.icon = () => renderRouteIcon(item.iconName, { size: 16 });
|
||||||
resolved.icon = () => h(ICON_MAP[item.iconName]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.children?.length) {
|
if (item.children?.length) {
|
||||||
|
|||||||
@@ -56,14 +56,12 @@ function confirmDelete({ record, onOk }: { record: any; onOk: (record: any) => v
|
|||||||
icon: null,
|
icon: null,
|
||||||
class: 'deleteConfirmModal',
|
class: 'deleteConfirmModal',
|
||||||
content: (
|
content: (
|
||||||
<>
|
|
||||||
<div class={pageStyles.confirmContent}>
|
<div class={pageStyles.confirmContent}>
|
||||||
<span>确定要删除角色</span>
|
<span>确定要删除角色</span>
|
||||||
<strong>{record.roleName}</strong>
|
<strong>{record.roleName}</strong>
|
||||||
<span>吗?</span>
|
<span>吗?</span>
|
||||||
</div>
|
|
||||||
<div class={pageStyles.confirmTip}>删除后该角色下的用户将失去对应的权限,请谨慎操作。</div>
|
<div class={pageStyles.confirmTip}>删除后该角色下的用户将失去对应的权限,请谨慎操作。</div>
|
||||||
</>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '确认',
|
okText: '确认',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
|
|||||||
@@ -73,5 +73,6 @@ router.afterEach((to) => {
|
|||||||
path: to.path,
|
path: to.path,
|
||||||
name: componentName,
|
name: componentName,
|
||||||
title: to.meta.title,
|
title: to.meta.title,
|
||||||
|
icon: to.meta.icon as string | undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-2
@@ -28,6 +28,8 @@ export interface TabView {
|
|||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** vue-icons-plus 图标名(如 AiOutlineTrophy) */
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 菜单 & 权限 ──
|
// ── 菜单 & 权限 ──
|
||||||
@@ -52,7 +54,7 @@ export interface MenuNode {
|
|||||||
* 留空或省略 → 纯布局节点(自动重定向到第一个子路由)
|
* 留空或省略 → 纯布局节点(自动重定向到第一个子路由)
|
||||||
*/
|
*/
|
||||||
component?: string;
|
component?: string;
|
||||||
/** 菜单图标(antd icon 名,如 DashboardOutlined) */
|
/** 菜单图标(vue-icons-plus 导出名,如 AiOutlineTrophy) */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** 排序权重(越小越靠前) */
|
/** 排序权重(越小越靠前) */
|
||||||
sort?: number;
|
sort?: number;
|
||||||
@@ -79,8 +81,9 @@ export interface MenuNode {
|
|||||||
*/
|
*/
|
||||||
export interface MenuItemRaw {
|
export interface MenuItemRaw {
|
||||||
key: string;
|
key: string;
|
||||||
|
/** 解析后的图标 VNode(传给 antd Menu) */
|
||||||
icon?: any;
|
icon?: any;
|
||||||
/** antd icon 名称字符串,用于动态解析图标 */
|
/** vue-icons-plus 图标名(如 AiOutlineTrophy) */
|
||||||
iconName?: string;
|
iconName?: string;
|
||||||
label: string;
|
label: string;
|
||||||
children?: MenuItemRaw[];
|
children?: MenuItemRaw[];
|
||||||
@@ -97,6 +100,7 @@ export type PermissionCode = string;
|
|||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** vue-icons-plus 图标名(如 AiOutlineTrophy) */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** 是否需要登录 */
|
/** 是否需要登录 */
|
||||||
requiresAuth?: boolean;
|
requiresAuth?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { h, type VNode } from 'vue';
|
||||||
|
import type { IconType } from 'vue-icons-plus/lib';
|
||||||
|
import {
|
||||||
|
AiOutlineAudit,
|
||||||
|
AiOutlineBarChart,
|
||||||
|
AiOutlineHistory,
|
||||||
|
AiOutlineMoneyCollect,
|
||||||
|
AiOutlinePayCircle,
|
||||||
|
AiOutlinePicture,
|
||||||
|
AiOutlineSafetyCertificate,
|
||||||
|
AiOutlineSetting,
|
||||||
|
AiOutlineShoppingCart,
|
||||||
|
AiOutlineTeam,
|
||||||
|
AiOutlineTransaction,
|
||||||
|
AiOutlineTrophy,
|
||||||
|
AiOutlineUnorderedList,
|
||||||
|
AiOutlineUser,
|
||||||
|
AiOutlineWallet,
|
||||||
|
} from 'vue-icons-plus/ai';
|
||||||
|
|
||||||
|
/** 路由 / 菜单 / 标签页使用的图标名 → vue-icons-plus 组件 */
|
||||||
|
const ROUTE_ICON_MAP: Record<string, IconType> = {
|
||||||
|
AiOutlineTrophy,
|
||||||
|
AiOutlineUnorderedList,
|
||||||
|
AiOutlineShoppingCart,
|
||||||
|
AiOutlineTeam,
|
||||||
|
AiOutlinePicture,
|
||||||
|
AiOutlineHistory,
|
||||||
|
AiOutlineTransaction,
|
||||||
|
AiOutlineMoneyCollect,
|
||||||
|
AiOutlineWallet,
|
||||||
|
AiOutlinePayCircle,
|
||||||
|
AiOutlineBarChart,
|
||||||
|
AiOutlineSetting,
|
||||||
|
AiOutlineUser,
|
||||||
|
AiOutlineSafetyCertificate,
|
||||||
|
AiOutlineAudit,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;
|
||||||
|
|
||||||
|
export interface RouteIconOptions {
|
||||||
|
size?: number | string;
|
||||||
|
class?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将路由 meta.icon 字符串渲染为图标 VNode */
|
||||||
|
export function renderRouteIcon(iconName?: string, options: RouteIconOptions = {}): VNode | null {
|
||||||
|
if (!iconName) return null;
|
||||||
|
|
||||||
|
const Icon = ROUTE_ICON_MAP[iconName];
|
||||||
|
if (!Icon) {
|
||||||
|
console.warn(`[RouteIcon] 未注册的图标: ${iconName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { size = 14, class: className, color } = options;
|
||||||
|
return h(Icon, {
|
||||||
|
size,
|
||||||
|
class: className,
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user