diff --git a/package.json b/package.json index 96538f3..5d6104e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "html2canvas": "^1.4.1", "qs": "^6.15.0", "vue": "^3.3.0", + "vue-icons-plus": "^0.1.8", "vue-router": "^4.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d937aa4..e2757e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,12 @@ importers: china-area-data: specifier: ^5.0.1 version: 5.0.1 - cropperjs: - specifier: ^1.6.2 - version: 1.6.2 dayjs: specifier: ^1.11.21 version: 1.11.21 + echarts: + specifier: ^5.6.0 + version: 5.6.0 express: specifier: ^5.2.1 version: 5.2.1 @@ -38,6 +38,9 @@ importers: vue: specifier: ^3.3.0 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: specifier: ^4.2.0 version: 4.6.4(vue@3.5.40(typescript@5.9.3)) @@ -1150,6 +1153,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2096,6 +2102,9 @@ packages: peerDependencies: typescript: '>=4.2.0' + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2182,6 +2191,11 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -2258,6 +2272,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + snapshots: '@ant-design/colors@6.0.0': @@ -3330,6 +3347,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.395: {} @@ -4325,6 +4347,8 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.3.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4388,6 +4412,10 @@ snapshots: transitivePeerDependencies: - 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)): dependencies: '@vue/devtools-api': 6.6.4 @@ -4459,3 +4487,7 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/src/assets/styles/index.less b/src/assets/styles/index.less index 2f0d68e..2bf63a1 100644 --- a/src/assets/styles/index.less +++ b/src/assets/styles/index.less @@ -114,3 +114,32 @@ a { .ant-tag { margin-right: 0; } + +// ===== 页面切换动画(KeepAlive + RouterView) ===== + +.page-fade-slide-enter-active, +.page-fade-slide-leave-active { + width: 100%; + height: 100%; + min-height: 0; + transition: + opacity 0.22s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +.page-fade-slide-enter-from { + opacity: 0; + transform: translateX(12px); +} + +.page-fade-slide-leave-to { + opacity: 0; + transform: translateX(-12px); +} + +.page-fade-slide-leave-active { + position: absolute; + inset: 0; + width: 100%; + overflow: hidden; +} diff --git a/src/assets/styles/pageLayout.module.less b/src/assets/styles/pageLayout.module.less index 503aba0..b5ba3fb 100644 --- a/src/assets/styles/pageLayout.module.less +++ b/src/assets/styles/pageLayout.module.less @@ -52,6 +52,12 @@ } } + // 表格内可点击数字链接(等宽数字,避免列宽抖动) + .tabularLink { + font-variant-numeric: tabular-nums; + cursor: pointer; + } + // ── 汇总 ── .summary { display: flex; @@ -88,3 +94,32 @@ } } } + +// ===== 二次确认弹窗 ===== + +.confirmContent { + padding: 4px 0; + font-size: 14px; + line-height: 1.6; + color: rgba(0, 0, 0, 0.85); + + strong { + margin: 0 4px; + font-weight: 600; + color: rgba(0, 0, 0, 0.88); + } + + .confirmTip { + margin-top: 8px; + font-size: 12px; + line-height: 1.5; + color: rgba(0, 0, 0, 0.45); + } +} + +:global(.toggleConfirmModal), +:global(.deleteConfirmModal) { + .ant-modal-confirm-body { + padding: 24px !important; + } +} diff --git a/src/components/StatusTag/StatusTag.module.less b/src/components/StatusTag/StatusTag.module.less new file mode 100644 index 0000000..57156b2 --- /dev/null +++ b/src/components/StatusTag/StatusTag.module.less @@ -0,0 +1,123 @@ +.statusTag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 1px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + border: 1px solid transparent; + letter-spacing: 0.2px; + + &.dimmed { + opacity: 0.55; + } + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + } +} + +.primary { + color: #1677ff; + background: rgba(22, 119, 255, 0.08); + border-color: rgba(22, 119, 255, 0.18); + + .dot { + background: #1677ff; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15); + } +} + +.success { + color: #389e0d; + background: rgba(82, 196, 26, 0.1); + border-color: rgba(82, 196, 26, 0.22); + + .dot { + background: #52c41a; + box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.15); + } +} + +.warning { + color: #d48806; + background: rgba(250, 173, 20, 0.12); + border-color: rgba(250, 173, 20, 0.28); + + .dot { + background: #faad14; + box-shadow: 0 0 0 2px rgba(250, 173, 20, 0.18); + } +} + +.danger { + color: #cf1322; + background: rgba(255, 77, 79, 0.08); + border-color: rgba(255, 77, 79, 0.2); + + .dot { + background: #ff4d4f; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.15); + } +} + +.orange { + color: #d46b08; + background: rgba(250, 140, 22, 0.1); + border-color: rgba(250, 140, 22, 0.22); + + .dot { + background: #fa8c16; + box-shadow: 0 0 0 2px rgba(250, 140, 22, 0.15); + } +} + +.cyan { + color: #08979c; + background: rgba(19, 194, 194, 0.1); + border-color: rgba(19, 194, 194, 0.22); + + .dot { + background: #13c2c2; + box-shadow: 0 0 0 2px rgba(19, 194, 194, 0.15); + } +} + +.purple { + color: #531dab; + background: rgba(114, 46, 209, 0.08); + border-color: rgba(114, 46, 209, 0.2); + + .dot { + background: #722ed1; + box-shadow: 0 0 0 2px rgba(114, 46, 209, 0.15); + } +} + +.neutral { + color: rgba(0, 0, 0, 0.55); + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.08); + + .dot { + background: rgba(0, 0, 0, 0.25); + box-shadow: none; + } +} + +.default { + color: rgba(0, 0, 0, 0.65); + background: #fafafa; + border-color: #f0f0f0; + + .dot { + background: rgba(0, 0, 0, 0.2); + box-shadow: none; + } +} diff --git a/src/components/StatusTag/StatusTag.tsx b/src/components/StatusTag/StatusTag.tsx new file mode 100644 index 0000000..59d47bd --- /dev/null +++ b/src/components/StatusTag/StatusTag.tsx @@ -0,0 +1,37 @@ +import { defineComponent, type PropType } from 'vue'; +import styles from './StatusTag.module.less'; + +export type StatusTagTone = + | 'primary' + | 'success' + | 'warning' + | 'danger' + | 'orange' + | 'cyan' + | 'purple' + | 'neutral' + | 'default'; + +const StatusTag = defineComponent({ + name: 'StatusTag', + props: { + label: { type: String, required: true }, + tone: { type: String as PropType, default: 'default' }, + showDot: { type: Boolean, default: true }, + dimmed: { type: Boolean, default: false }, + }, + setup(props) { + return () => ( + + {props.showDot ? : null} + {props.label} + + ); + }, +}); + +export default StatusTag; diff --git a/src/components/StatusTag/index.ts b/src/components/StatusTag/index.ts new file mode 100644 index 0000000..50b9a0f --- /dev/null +++ b/src/components/StatusTag/index.ts @@ -0,0 +1,2 @@ +export { default as StatusTag } from './StatusTag'; +export type { StatusTagTone } from './StatusTag'; diff --git a/src/components/index.ts b/src/components/index.ts index cfe65a6..fc4b600 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,4 +4,6 @@ */ export { default as HelloWorld } from './HelloWorld'; export { default as ChangePasswordModal } from './ChangePasswordModal'; -export { default as PageTabs } from './pageTabs/PageTabs'; +export { default as PageTabs } from './PageTabs/PageTabs'; +export { StatusTag } from './StatusTag'; +export type { StatusTagTone } from './StatusTag'; diff --git a/src/components/pageTabs/PageTabs.module.less b/src/components/pageTabs/PageTabs.module.less index fe8a93d..b94d883 100644 --- a/src/components/pageTabs/PageTabs.module.less +++ b/src/components/pageTabs/PageTabs.module.less @@ -1,164 +1,229 @@ @primary: #1677ff; -.pageTabsMain { - display: flex; - align-items: center; +.pageTabsWrap { + position: relative; width: 100%; height: 100%; - overflow-x: auto; - overflow-y: hidden; - &::-webkit-scrollbar { - height: 0; - } - - .tabItem { - position: relative; - display: flex; + .navBtn { + position: absolute; + top: 50%; + z-index: 5; + display: inline-flex; align-items: center; - height: 100%; - user-select: none; - transition: all 0.15s ease; - padding: 0 8px 0 14px; + 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; - &:global(:not(.dragging)) { - cursor: pointer; + &:hover { + color: rgba(0, 0, 0, 0.88); + background: #f5f5f5; } - &:not(.tabItemActive) { - &:hover { - > .divider { - opacity: 0; - } + &:active { + color: rgba(0, 0, 0, 0.88); + background: #ebebeb; + } + } - & + .tabItem { + .navBtnLeft { + left: 0; + } + + .navBtnRight { + right: 0; + } + + .pageTabs { + display: flex; + align-items: flex-start; + width: 100%; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + .tabItem { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + height: 100%; + user-select: none; + transition: all 0.15s ease; + + &:not(:first-child) { + margin-left: -10px; + } + + &:global(:not(.dragging)) { + cursor: pointer; + } + + &:not(.tabItemActive) { + &:hover { > .divider { opacity: 0; } + + & + .tabItem { + > .divider { + opacity: 0; + } + } + + .tabContent { + background-color: rgba(0, 0, 0, 0.06); + } + } + + .tabClose:hover { + color: rgba(0, 0, 0, 0.85); + } + } + + &.tabItemActive { + z-index: 2; + + & + .tabItem { + > .divider { + opacity: 0 !important; + } + } + + .tabTitle { + color: @primary; + font-weight: 500; + } + + .tabIcon { + color: @primary; + } + + .tabBg { + &.tabBgActive { + .tabBgInner { + background-color: fade(@primary, 8%); + } + + .tabBgCurveBefore, + .tabBgCurveAfter { + fill: fade(@primary, 8%); + } + } } } } - &.tabItemActive { + .divider { + position: absolute; + left: 8px; + top: 50%; + z-index: 0; + width: 1px; + height: 18px; + background-color: #e8e8e8; + transform: translateY(-50%); + transition: opacity 0.15s; + } + + .tabBg { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + padding: 0 calc(8px - 1px); + transition: opacity 0.15s; + } + + .tabBgInner { + height: 100%; + border-radius: 8px 8px 0 0; + transition: + background-color 0.15s, + margin 0.15s, + border-radius 0.15s; + } + + .tabBgCurveBefore { + position: absolute; + left: -1px; + bottom: 0; + fill: transparent; + transition: fill 0.15s; + } + + .tabBgCurveAfter { + position: absolute; + right: -1px; + bottom: 0; + fill: transparent; + transition: fill 0.15s; + } + + .tabContent { + position: relative; + height: 34px; 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%); - } - } - } + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + flex-shrink: 0; + margin: 0 12px; + padding: 4px 4px 4px 10px; + border-radius: 9px; + transition: background-color 0.35s; } - &:not(.tabItemActive) { - .tabTitleCon:hover { - background-color: rgba(0, 0, 0, 0.06); - } + .tabIcon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: rgba(0, 0, 0, 0.45); + line-height: 1; + font-size: 16px; } - } - .divider { - position: absolute; - left: 7px; - top: 50%; - z-index: 0; - width: 1px; - height: 16px; - background-color: #e8e8e8; - transform: translateY(-50%); - transition: opacity 0.15s; - } - - .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; - } - - .tabBgCurveBefore { - position: absolute; - left: -1px; - bottom: 0; - fill: transparent; - transition: fill 0.15s; - } - - .tabBgCurveAfter { - position: absolute; - right: -1px; - bottom: 0; - fill: transparent; - transition: fill 0.15s; - } - - .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); + .tabTitle { + min-width: 0; + max-width: 160px; + font-size: 14px; + line-height: 1.4; + color: rgba(0, 0, 0, 0.65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - } - .tabTitleCon { - height: 28px; - display: flex; - justify-content: center; - align-items: center; - border-radius: 4px; - } - - .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; + .tabClose { + flex-shrink: 0; + width: 18px; + height: 18px; + line-height: 18px; + font-size: 11px; + text-align: center; + color: rgba(0, 0, 0, 0.45); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s; + } } } diff --git a/src/components/pageTabs/PageTabs.tsx b/src/components/pageTabs/PageTabs.tsx index fa0d8df..ebd4d32 100644 --- a/src/components/pageTabs/PageTabs.tsx +++ b/src/components/pageTabs/PageTabs.tsx @@ -1,16 +1,27 @@ -import { defineComponent, type PropType } from 'vue'; +import { + defineComponent, + nextTick, + onBeforeUnmount, + onMounted, + ref, + type PropType, + watch, +} from 'vue'; import { message } from 'ant-design-vue'; +import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue'; import type { TabView } from '@/types'; +import { useState } from '@/hooks'; +import { renderRouteIcon } from '@/utils/routeIcon'; import styles from './PageTabs.module.less'; +const SCROLL_STEP = 240; + +type TabKeyHandler = (key: string) => void; + /** * Chrome 风格的页面标签栏组件 - * 参考 VBEN tabs-chrome 的视觉设计: - * - 标签重叠排列(负 margin) - * - 激活态:圆角背景 + SVG 底部曲线 - * - hover 态:浅灰背景 - * - 标签间分隔线 - * - 关闭按钮 + * - 无滚动条,滚轮横向滚动 + * - 溢出时显示左右导航按钮 */ const PageTabs = defineComponent({ name: 'PageTabs', @@ -20,11 +31,74 @@ const PageTabs = defineComponent({ /** 当前激活 key */ activeKey: { type: String, required: true }, /** 关闭标签 */ - onClose: { type: Function as PropType<(key: string) => void>, default: undefined }, + onClose: { type: Function as PropType, default: undefined }, /** 切换标签 */ - onChange: { type: Function as PropType<(key: string) => void>, default: undefined }, + onChange: { type: Function as PropType, default: undefined }, }, setup(props) { + const scrollRef = ref(); + const tabItemRefs = ref>({}); + const [showNavLeft, setShowNavLeft] = useState(false); + const [showNavRight, setShowNavRight] = useState(false); + + let resizeObserver: ResizeObserver | null = null; + + const updateScrollState = () => { + const el = scrollRef.value; + if (!el) { + setShowNavLeft(false); + setShowNavRight(false); + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = el; + const canScroll = scrollWidth - clientWidth > 1; + + setShowNavLeft(canScroll && scrollLeft > 1); + setShowNavRight(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; + return; + } + + delete tabItemRefs.value[path]; + }; + const handleClick = (key: string) => { if (key !== props.activeKey && props.onChange) { props.onChange(key); @@ -42,47 +116,91 @@ const PageTabs = defineComponent({ } }; - return () => ( -
- {props.tabs.map((tab, i) => { - const isActive = tab.path === props.activeKey; - return ( -
handleClick(tab.path)} - > - {/* 分隔线:第一个标签 / 激活标签 / 上一个就是激活的 → 不显示 */} -
+ onMounted(() => { + const el = scrollRef.value; + if (!el) return; - {/* Chrome 风格背景:激活态 */} -
-
- {/* 左下曲线 */} - - - - {/* 右下曲线 */} - - - -
+ el.addEventListener('wheel', handleWheel, { passive: false }); + el.addEventListener('scroll', updateScrollState, { passive: true }); -
- {/* 标签文字(flex 子元素,与关闭按钮同行) */} - {tab.title} -
+ resizeObserver = new ResizeObserver(updateScrollState); + resizeObserver.observe(el); + updateScrollState(); + }); - {/* 关闭按钮(flex 子元素,不参与 shrink) */} - handleClose(tab.path, e)}> - ✕ - + 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 showDivider = i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey; + + return ( +
setTabItemRef(tab.path, el as Element | null)} + class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')} + onClick={() => handleClick(tab.path)} + > +
+ +
+
+ + + + + + +
+ +
+ {tab.icon ? ( + {renderRouteIcon(tab.icon, { size: 16 })} + ) : null} +
{tab.title}
+
handleClose(tab.path, e)}> + ✕
- ); - })} +
+
+ ); + }; + + return () => ( +
+ {showNavLeft.value && ( + + )} + +
+ {props.tabs.map((tab, i) => renderTab(tab, i))} +
+ + {showNavRight.value && ( + + )}
); }, diff --git a/src/config/fallbackRoutes.ts b/src/config/fallbackRoutes.ts index be5d1c2..88d458b 100644 --- a/src/config/fallbackRoutes.ts +++ b/src/config/fallbackRoutes.ts @@ -11,12 +11,11 @@ import type { MenuNode } from '@/types'; * * ```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": [ - * { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list" }, - * { "id": "events_orders", "name": "订单管理", "path": "events/orders", "component": "events/orders" } + * { "id": "events_list", "name": "赛事列表", "path": "events/list", "component": "events/list", "icon": "AiOutlineUnorderedList" } * ] * } * ] @@ -29,7 +28,7 @@ import type { MenuNode } from '@/types'; * | name | string | 是 | 菜单名称/路由 name | * | path | string | 是 | 路由相对路径(不带 / 前缀) | * | component | string | 否 | 组件路径(映射 src/pages/ 下目录,如 "events/list") | - * | icon | string | 否 | antd 图标名(如 DashboardOutlined) | + * | icon | string | 否 | vue-icons-plus 图标名(如 AiOutlineTrophy,需在 routeIcon.tsx 注册) | * | sort | number | 否 | 排序权重(越小越前) | * | hideInMenu | boolean | 否 | 是否在菜单中隐藏 | * | externalLink | string | 否 | 外链地址 | @@ -47,7 +46,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ id: 'events', name: '赛事管理', path: 'events', - icon: 'TrophyOutlined', + icon: 'AiOutlineTrophy', children: [ { id: 'events_list', @@ -55,6 +54,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'events/list', component: 'events/list', componentName: 'EventList', + icon: 'AiOutlineUnorderedList', }, { id: 'events_orders', @@ -62,6 +62,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'events/orders', component: 'events/orders', componentName: 'EventOrders', + icon: 'AiOutlineShoppingCart', }, { id: 'events_users', @@ -69,6 +70,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'events/users', component: 'events/users', componentName: 'EventUsers', + icon: 'AiOutlineTeam', }, { id: 'events_banner', @@ -76,6 +78,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'events/banner', component: 'events/banner', componentName: 'EventBanner', + icon: 'AiOutlinePicture', }, { id: 'events_logs', @@ -83,6 +86,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'events/logs', component: 'events/logs', componentName: 'EventLogs', + icon: 'AiOutlineHistory', }, ], }, @@ -90,7 +94,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ id: 'finance', name: '账务管理', path: 'finance', - icon: 'TransactionOutlined', + icon: 'AiOutlineTransaction', children: [ { id: 'finance_withdraw', @@ -98,6 +102,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'finance/withdraw', component: 'finance/withdraw', componentName: 'FinanceWithdraw', + icon: 'AiOutlineMoneyCollect', }, { id: 'finance_wallet', @@ -105,6 +110,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'finance/wallet', component: 'finance/wallet', componentName: 'FinanceWallet', + icon: 'AiOutlineWallet', }, { id: 'finance_payments', @@ -112,6 +118,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'finance/payments', component: 'finance/payments', componentName: 'FinancePayments', + icon: 'AiOutlinePayCircle', }, { id: 'finance_reports', @@ -119,6 +126,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'finance/reports', component: 'finance/reports', componentName: 'FinanceReports', + icon: 'AiOutlineBarChart', }, ], }, @@ -126,7 +134,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ id: 'system', name: '系统管理', path: 'system', - icon: 'SettingOutlined', + icon: 'AiOutlineSetting', children: [ { id: 'system_users', @@ -134,6 +142,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'system/users', component: 'system/users', componentName: 'SystemUsers', + icon: 'AiOutlineUser', }, { id: 'system_roles', @@ -141,6 +150,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'system/roles', component: 'system/roles', componentName: 'SystemRoles', + icon: 'AiOutlineSafetyCertificate', }, { id: 'system_logs', @@ -148,6 +158,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ path: 'system/logs', component: 'system/logs', componentName: 'SystemLogs', + icon: 'AiOutlineAudit', }, ], }, diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 275228a..9b10755 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -3,6 +3,7 @@ import { useState } from './useState'; import { useEffect } from './useEffect'; import { useMenuStore } from '@/stores/menuStore'; import { usePermissionStore } from '@/stores/permissionStore'; +import { useTabsStore } from '@/stores/tabsStore'; import router from '@/router'; const TOKEN_KEY = 'MY_APP_AUTH_TOKEN'; @@ -24,17 +25,20 @@ function createAuth() { /** * 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页 */ - const login = async (newToken: string) => { + const login = async (newToken: string, targetPath?: string) => { setToken(newToken); - const { loadMenu, homePath } = useMenuStore(); + const { loadMenu, homePath, isRoutePathAvailable } = useMenuStore(); const { loadPermissions } = usePermissionStore(); // 并行拉取菜单和权限 await Promise.all([loadMenu(), loadPermissions()]); - // 跳转到首页(动态路由已注册完成) - router.push(homePath.value); + const nextPath = + targetPath && isRoutePathAvailable(targetPath) ? targetPath : homePath.value || '/404'; + + // 动态路由注册完成后,再跳转到有效目标页 + router.push(nextPath); }; /** @@ -45,7 +49,9 @@ function createAuth() { const { clearMenu } = useMenuStore(); const { clearPermissions } = usePermissionStore(); + const { clearTabs } = useTabsStore(); + clearTabs(); clearMenu(); clearPermissions(); diff --git a/src/layouts/BasicLayout.module.less b/src/layouts/BasicLayout.module.less index 910da44..2a25135 100644 --- a/src/layouts/BasicLayout.module.less +++ b/src/layouts/BasicLayout.module.less @@ -79,20 +79,6 @@ } } - .menuItem { - display: inline-flex; - align-items: center; - gap: 8px; - - :global(.anticon) { - font-size: 14px; - } - } - - .menuItemDanger { - color: #ff4d4f; - } - .content { margin: 16px; border-radius: 8px; @@ -103,6 +89,15 @@ overflow: hidden; } + .pageView { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + .main { flex: 1; min-height: 0; @@ -120,9 +115,27 @@ .pageTabs { width: 100%; - padding: 4px 16px 0 16px; - height: 42px; + height: 48px; + padding: 4px 8px 0; background: #fff; border-top: 1px solid #f0f0f0; } } + +/** + * Dropdown 菜单通过 portal 挂到 body,不在 .basicLayoutMain 内。 + * 若嵌套在 .basicLayoutMain 下,CSS Modules 会生成「父级 + 子级」选择器,导致匹配失败。 + */ +.menuItem { + display: inline-flex; + align-items: center; + gap: 8px; + + :global(.anticon) { + font-size: 14px; + } +} + +.menuItemDanger { + color: #ff4d4f; +} diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index d9d59cf..74fb457 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -1,25 +1,20 @@ -import { computed, defineComponent, h, ref } from 'vue'; +import { computed, defineComponent, h, Transition, KeepAlive } 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'; import { - DashboardOutlined, EditOutlined, - InfoCircleOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - SettingOutlined, - TransactionOutlined, - TrophyOutlined, } 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, PageTabs } from '@/components'; +import { renderRouteIcon } from '@/utils/routeIcon'; import logoutSvg from '@/assets/img/icon/logout.svg'; import styles from './BasicLayout.module.less'; @@ -48,22 +43,9 @@ const footerStyle: CSSProperties = { userSelect: 'none', }; -/** - * 图标名 → 组件映射 - * 后端下发的 icon 字符串在此映射为 antd icon 组件 - * 扩展新图标只需在此添加一行 - */ -const ICON_MAP: Record = { - DashboardOutlined, - InfoCircleOutlined, - SettingOutlined, - TransactionOutlined, - TrophyOutlined, -}; - /** * 将 menuStore 中的 menuItems 转为 antd Menu 的 items 格式 - * 递归处理 iconName → icon VNode + * 递归处理 iconName → vue-icons-plus 图标 VNode */ function resolveMenuItems(items: any[]): MenuProps['items'] { return items.map((item) => { @@ -72,9 +54,8 @@ function resolveMenuItems(items: any[]): MenuProps['items'] { label: item.label, }; - // iconName 字符串转为 VNode - if (item.iconName && ICON_MAP[item.iconName]) { - resolved.icon = () => h(ICON_MAP[item.iconName]); + if (item.iconName) { + resolved.icon = () => renderRouteIcon(item.iconName, { size: 16 }); } if (item.children?.length) { @@ -93,7 +74,7 @@ export default defineComponent({ setup() { const route = useRoute(); const router = useRouter(); - const collapsed = ref(false); + const [collapsed, setCollapsed] = useState(false); const { menuItems } = useMenuStore(); const { phone } = useUserStore(); @@ -109,7 +90,7 @@ export default defineComponent({ }; const toggleCollapsed = () => { - collapsed.value = !collapsed.value; + setCollapsed(!collapsed.value); }; const activeTabKey = computed(() => route.path); @@ -263,15 +244,19 @@ export default defineComponent({ include 是组件 name 列表,所以页面组件需要在 defineComponent 中显式给出 name */} - ( - - - - ), - }} - /> +
+ ( + + + {Component ? h(Component, { key: route.path }) : null} + + + ), + }} + /> +
六个羽友赛事运营平台 ©{new Date().getFullYear()}
diff --git a/src/pages/events/banner/components/BannerFormModal.module.less b/src/pages/events/banner/components/BannerFormModal.module.less index 5259e15..1306990 100644 --- a/src/pages/events/banner/components/BannerFormModal.module.less +++ b/src/pages/events/banner/components/BannerFormModal.module.less @@ -38,7 +38,7 @@ } .uploadIcon { - font-size: 32px; + font-size: 24px; color: #999; font-weight: 300; line-height: 1; diff --git a/src/pages/events/banner/components/BannerFormModal.tsx b/src/pages/events/banner/components/BannerFormModal.tsx index 7630878..89f311e 100644 --- a/src/pages/events/banner/components/BannerFormModal.tsx +++ b/src/pages/events/banner/components/BannerFormModal.tsx @@ -1,5 +1,5 @@ import { defineComponent, ref, reactive } from 'vue'; -import { useEffect } from '@/hooks'; +import { useEffect, useState } from '@/hooks'; import { Modal, Form, @@ -13,6 +13,7 @@ import { } from 'ant-design-vue'; import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel'; import { ImageCropper } from '@yp-component/root'; +import { FileImageOutlined } from '@ant-design/icons-vue'; import { uploadFile, OssUploadType } from '@/utils/oss'; import dayjs from 'dayjs'; import styles from './BannerFormModal.module.less'; @@ -81,9 +82,9 @@ export default defineComponent({ const formData = reactive(getDefaultForm()); /** 裁剪弹窗状态 */ - const cropperVisible = ref(false); + const [cropperVisible, setCropperVisible] = useState(false); /** 待裁剪图片的本地预览 URL */ - const uploadImageUrl = ref(''); + const [uploadImageUrl, setUploadImageUrl] = useState(''); /** 根据 record 初始化表单 */ const initFormFromRecord = (record: any) => { @@ -123,8 +124,8 @@ export default defineComponent({ useEffect(() => { if (props.visible) { initFormFromRecord(props.record); - cropperVisible.value = false; - uploadImageUrl.value = ''; + setCropperVisible(false); + setUploadImageUrl(''); setTimeout(() => formRef.value?.clearValidate(), 0); } }, [() => props.visible]); @@ -166,13 +167,13 @@ export default defineComponent({ } // 生成本地预览 URL,打开裁剪弹窗 - uploadImageUrl.value = URL.createObjectURL(file); - cropperVisible.value = true; + setUploadImageUrl(URL.createObjectURL(file)); + setCropperVisible(true); }; /** 裁剪确认回调:将裁剪后的图片上传到 OSS */ const handleCropConfirm = async (dataUrl: string) => { - cropperVisible.value = false; + setCropperVisible(false); // 释放旧的本地预览 if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) { @@ -205,16 +206,16 @@ export default defineComponent({ if (uploadImageUrl.value) { URL.revokeObjectURL(uploadImageUrl.value); } - uploadImageUrl.value = ''; + setUploadImageUrl(''); }; /** 裁剪弹窗取消 */ const onCropperCancel = () => { - cropperVisible.value = false; + setCropperVisible(false); if (uploadImageUrl.value) { URL.revokeObjectURL(uploadImageUrl.value); } - uploadImageUrl.value = ''; + setUploadImageUrl(''); }; /** 删除已上传图片 */ @@ -317,7 +318,9 @@ export default defineComponent({ }`} onClick={triggerFilePicker} > - + + + + 点击上传 建议尺寸 1053*351 像素 diff --git a/src/pages/events/banner/index.module.less b/src/pages/events/banner/index.module.less deleted file mode 100644 index d7c3c7d..0000000 --- a/src/pages/events/banner/index.module.less +++ /dev/null @@ -1,21 +0,0 @@ -.eventBannerMain { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - - .tableBody { - flex: 1; - min-height: 0; - overflow: hidden; - - :global { - .ant-spin-nested-loading, - .ant-spin-container, - .ant-table, - .ant-table-container { - height: 100%; - } - } - } -} diff --git a/src/pages/events/banner/index.tsx b/src/pages/events/banner/index.tsx index e54c205..71865f0 100644 --- a/src/pages/events/banner/index.tsx +++ b/src/pages/events/banner/index.tsx @@ -14,7 +14,6 @@ import { import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel'; import { useContainerSize } from '@/hooks'; import BannerFormModal from './components/BannerFormModal'; -import styles from './index.module.less'; import pageStyles from '@/assets/styles/pageLayout.module.less'; /** @@ -66,7 +65,7 @@ function renderBodyCell({ ); } - // 跳转类型列:彩色 Tag + // 跳转类型列:StatusTag if (column.dataIndex === 'jumpType') { return renderJumpType(text); } @@ -231,41 +230,39 @@ export default defineComponent({ {/* ===== 表格区 ===== */}
-
-
- - {{ - bodyCell: (args: any) => - renderBodyCell({ - ...args, - renderJumpType, - renderStatus, - onEdit: handleEdit, - onToggleStatus: confirmToggleStatus, - onDelete: confirmDelete, - }), - }} -
-
+
+ + {{ + bodyCell: (args: any) => + renderBodyCell({ + ...args, + renderJumpType, + renderStatus, + onEdit: handleEdit, + onToggleStatus: confirmToggleStatus, + onDelete: confirmDelete, + }), + }} +
+
- {/* 独立分页,右下方 */} -
- `共 ${total} 条`} - onChange={handlePageChange} - onShowSizeChange={handlePageChange} - /> -
+ {/* 独立分页,右下方 */} +
+ `共 ${total} 条`} + onChange={handlePageChange} + onShowSizeChange={handlePageChange} + />
diff --git a/src/pages/events/banner/model/useBannerModel.ts b/src/pages/events/banner/model/useBannerModel.ts index 1cbab9f..9ee00f5 100644 --- a/src/pages/events/banner/model/useBannerModel.ts +++ b/src/pages/events/banner/model/useBannerModel.ts @@ -1,5 +1,6 @@ import { computed, reactive, toRef, Ref, h } from 'vue'; -import { message, Tag } from 'ant-design-vue'; +import { message } from 'ant-design-vue'; +import { StatusTag, type StatusTagTone } from '@/components'; import { useState, useDebounce, useThrottleFn } from '@/hooks'; // ============================================================ @@ -21,18 +22,18 @@ export const JUMP_TYPE_OPTIONS = [ { value: 'none', label: '无链接' }, ] as const; -/** 跳转类型映射(用于表格 Tag 渲染) */ -const JUMP_TYPE_MAP: Record = { - event_detail: { label: '赛事详情页', color: 'blue' }, - miniprogram_page: { label: '小程序页面', color: 'cyan' }, - custom_link: { label: '自定义链接', color: 'purple' }, - none: { label: '无链接', color: 'default' }, +/** 跳转类型映射(用于表格 StatusTag 渲染) */ +const JUMP_TYPE_MAP: Record = { + event_detail: { label: '赛事详情页', tone: 'primary' }, + miniprogram_page: { label: '小程序页面', tone: 'cyan' }, + custom_link: { label: '自定义链接', tone: 'purple' }, + none: { label: '无链接', tone: 'default' }, }; /** Banner 状态映射 */ -const STATUS_MAP: Record = { - enabled: { label: '启用中', color: 'green' }, - disabled: { label: '已禁用', color: 'default' }, +const STATUS_MAP: Record = { + enabled: { label: '启用中', tone: 'success' }, + disabled: { label: '已禁用', tone: 'default', dimmed: true }, }; /** 关联赛事选项(mock) */ @@ -153,7 +154,6 @@ export function useBannerModel() { dataIndex: 'jumpType', key: 'jumpType', width: 120, - align: 'center' as const, }, { title: '关联赛事', dataIndex: 'relatedEvent', key: 'relatedEvent', width: 200 }, { title: '所属区域', key: 'region', width: 140 }, @@ -282,18 +282,17 @@ export function useBannerModel() { // ===== 供页面 bodyCell 使用的渲染工具 ===== const renderJumpType = (type: string) => { - const info = JUMP_TYPE_MAP[type] || { label: type || '-', color: 'default' }; - return h(Tag, { color: info.color }, () => info.label); + const info = JUMP_TYPE_MAP[type] || { label: type || '-', tone: 'default' as const }; + return h(StatusTag, { label: info.label, tone: info.tone }); }; const renderStatus = (status: string) => { - const info = STATUS_MAP[status] || { label: status || '-', color: 'default' }; - const tagProps: any = { color: info.color }; - // 已禁用状态添加半透明样式 - if (status === 'disabled') { - tagProps.style = { opacity: 0.5 }; - } - return h(Tag, tagProps, () => info.label); + const info = STATUS_MAP[status] || { label: status || '-', tone: 'default' as const }; + return h(StatusTag, { + label: info.label, + tone: info.tone, + dimmed: info.dimmed, + }); }; return { diff --git a/src/pages/events/list/index.module.less b/src/pages/events/list/index.module.less deleted file mode 100644 index a5eb239..0000000 --- a/src/pages/events/list/index.module.less +++ /dev/null @@ -1,21 +0,0 @@ -.eventListMain { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - - .tableBody { - flex: 1; - min-height: 0; - overflow: hidden; - - :global { - .ant-spin-nested-loading, - .ant-spin-container, - .ant-table, - .ant-table-container { - height: 100%; - } - } - } -} diff --git a/src/pages/events/list/index.tsx b/src/pages/events/list/index.tsx index 85a5335..682785f 100644 --- a/src/pages/events/list/index.tsx +++ b/src/pages/events/list/index.tsx @@ -25,7 +25,6 @@ import { useContainerSize, useState } from '@/hooks'; import EventRegulationModal from './components/EventRegulationModal'; import EventDetailModal from './components/EventDetailModal'; import EventAuditModal from './components/EventAuditModal'; -import styles from './index.module.less'; import pageStyles from '@/assets/styles/pageLayout.module.less'; const { RangePicker } = DatePicker; @@ -245,7 +244,7 @@ export default defineComponent({ (filterForm.orderStatus = val || '')} /> @@ -185,7 +184,7 @@ export default defineComponent({ (filterForm.role = val || '')} /> @@ -145,7 +142,7 @@ export default defineComponent({