diff --git a/src/components/pageTabs/PageTabs.tsx b/src/components/pageTabs/PageTabs.tsx index 870c83f..ebd4d32 100644 --- a/src/components/pageTabs/PageTabs.tsx +++ b/src/components/pageTabs/PageTabs.tsx @@ -3,13 +3,14 @@ import { nextTick, onBeforeUnmount, onMounted, - type PropType, 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'; @@ -37,24 +38,24 @@ const PageTabs = defineComponent({ setup(props) { const scrollRef = ref(); const tabItemRefs = ref>({}); - const showNavLeft = ref(false); - const showNavRight = ref(false); + const [showNavLeft, setShowNavLeft] = useState(false); + const [showNavRight, setShowNavRight] = useState(false); let resizeObserver: ResizeObserver | null = null; const updateScrollState = () => { const el = scrollRef.value; if (!el) { - showNavLeft.value = false; - showNavRight.value = false; + setShowNavLeft(false); + setShowNavRight(false); return; } const { scrollLeft, scrollWidth, clientWidth } = el; const canScroll = scrollWidth - clientWidth > 1; - showNavLeft.value = canScroll && scrollLeft > 1; - showNavRight.value = canScroll && scrollLeft + clientWidth < scrollWidth - 1; + setShowNavLeft(canScroll && scrollLeft > 1); + setShowNavRight(canScroll && scrollLeft + clientWidth < scrollWidth - 1); }; const scrollByStep = (direction: -1 | 1) => { @@ -92,9 +93,10 @@ const PageTabs = defineComponent({ const setTabItemRef = (path: string, el: Element | null) => { if (el) { tabItemRefs.value[path] = el as HTMLElement; - } else { - delete tabItemRefs.value[path]; + return; } + + delete tabItemRefs.value[path]; }; const handleClick = (key: string) => { 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 e165d81..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; @@ -135,3 +121,21 @@ 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 52fc4ab..74fb457 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -1,4 +1,4 @@ -import { computed, defineComponent, h, ref, Transition, KeepAlive } from 'vue'; +import { computed, defineComponent, h, Transition, KeepAlive } from 'vue'; import type { CSSProperties } from 'vue'; import { useRouter, useRoute, RouterView } from 'vue-router'; import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue'; @@ -74,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(); @@ -90,7 +90,7 @@ export default defineComponent({ }; const toggleCollapsed = () => { - collapsed.value = !collapsed.value; + setCollapsed(!collapsed.value); }; const activeTabKey = computed(() => route.path); diff --git a/src/pages/events/banner/components/BannerFormModal.tsx b/src/pages/events/banner/components/BannerFormModal.tsx index fda3805..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, @@ -82,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) => { @@ -124,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]); @@ -167,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:')) { @@ -206,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(''); }; /** 删除已上传图片 */ diff --git a/src/pages/events/orders/components/OrderDetailModal.tsx b/src/pages/events/orders/components/OrderDetailModal.tsx index 0e03f80..66ad6fa 100644 --- a/src/pages/events/orders/components/OrderDetailModal.tsx +++ b/src/pages/events/orders/components/OrderDetailModal.tsx @@ -1,6 +1,7 @@ -import { defineComponent, ref, computed } from 'vue'; +import { defineComponent, computed } from 'vue'; import { Modal, Table, Button, Pagination, Image, Space } from 'ant-design-vue'; import { StatusTag, type StatusTagTone } from '@/components'; +import { useState } from '@/hooks'; import styles from './OrderDetailModal.module.less'; interface OrderDetailModalProps { @@ -84,8 +85,8 @@ export default defineComponent({ onReRefund: { type: Function, required: true }, }, setup(props: OrderDetailModalProps) { - const refundPage = ref(1); - const refundPageSize = ref(2); + const [refundPage, setRefundPage] = useState(1); + const [refundPageSize, setRefundPageSize] = useState(2); /** 退款记录 */ const refundRecords = computed(() => props.record?.refundRecords || []); @@ -105,8 +106,8 @@ export default defineComponent({ ); const handleRefundPageChange = (page: number, pageSize: number) => { - refundPage.value = page; - refundPageSize.value = pageSize; + setRefundPage(page); + setRefundPageSize(pageSize); }; const handleConfirm = () => { diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 590deae..86049e8 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -1,8 +1,9 @@ -import { defineComponent, reactive, ref } from 'vue'; +import { defineComponent, reactive } from 'vue'; +import { useRoute } from 'vue-router'; import { Button, Checkbox, Form, FormItem, Input, message } from 'ant-design-vue'; import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'; import { auth } from '@/hooks/useAuth'; -import { useEffect } from '@/hooks/useEffect'; +import { useEffect, useState } from '@/hooks'; import { useUserStore } from '@/stores/userStore'; import styles from './index.module.less'; @@ -14,8 +15,9 @@ interface LoginForm { export default defineComponent({ name: 'LoginPage', setup() { - const loading = ref(false); - const rememberMe = ref(false); + const route = useRoute(); + const [loading, setLoading] = useState(false); + const [rememberMe, setRememberMe] = useState(false); const form = reactive({ username: '', @@ -29,7 +31,7 @@ export default defineComponent({ const data = JSON.parse(saved); form.username = data.username || ''; form.password = data.password || ''; - rememberMe.value = true; + setRememberMe(true); } catch { localStorage.removeItem('cpms_login_remember'); } @@ -47,7 +49,7 @@ export default defineComponent({ return; } - loading.value = true; + setLoading(true); try { await new Promise((r) => setTimeout(r, 600)); @@ -57,7 +59,10 @@ export default defineComponent({ const { setUser } = useUserStore(); setUser({ phone: form.username, nickname: form.username, token }); - await auth.login(token); + const redirect = Array.isArray(route.query.redirect) + ? route.query.redirect[0] + : route.query.redirect; + await auth.login(token, redirect || undefined); message.success('登录成功'); if (rememberMe.value) { @@ -74,7 +79,7 @@ export default defineComponent({ } catch (err) { message.error('登录失败,请重试'); } finally { - loading.value = false; + setLoading(false); } }; diff --git a/src/pages/system/users/components/UserFormModal.tsx b/src/pages/system/users/components/UserFormModal.tsx index 5146f24..e5f44dd 100644 --- a/src/pages/system/users/components/UserFormModal.tsx +++ b/src/pages/system/users/components/UserFormModal.tsx @@ -1,5 +1,5 @@ import { defineComponent, ref, reactive } from 'vue'; -import { useEffect } from '@/hooks'; +import { useEffect, useState } from '@/hooks'; import { Modal, Form, Input, Select, Button } from 'ant-design-vue'; import { ROLE_FORM_OPTIONS, @@ -43,14 +43,14 @@ export default defineComponent({ const formData = reactive(getDefaultForm()); /** 编辑模式下是否处于"重置密码"状态 */ - const isResetting = ref(false); + const [isResetting, setIsResetting] = useState(false); /** 根据 record 初始化表单 */ const initFormFromRecord = (record: any) => { const fresh = getDefaultForm(); if (!record) { Object.assign(formData, fresh); - isResetting.value = false; + setIsResetting(false); return; } Object.assign(formData, { @@ -59,21 +59,21 @@ export default defineComponent({ phone: record.phone || '', role: record.role || '', }); - isResetting.value = false; + setIsResetting(false); }; /** 监听 visible 变化重置表单 */ useEffect(() => { if (props.visible) { initFormFromRecord(props.record); - isResetting.value = false; + setIsResetting(false); setTimeout(() => formRef.value?.clearValidate(), 0); } }, [() => props.visible]); /** 点击"重置密码" */ const handleResetPassword = () => { - isResetting.value = true; + setIsResetting(true); formData.password = ''; formData.confirmPassword = ''; }; diff --git a/src/router/guard.ts b/src/router/guard.ts index 87f1589..9462d6a 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -1,6 +1,7 @@ import router from '@/router'; import { auth } from '@/hooks/useAuth'; import { useTabsStore } from '@/stores/tabsStore'; +import { useMenuStore } from '@/stores/menuStore'; const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台'; @@ -9,6 +10,11 @@ function updateDocumentTitle(title?: string) { document.title = title ? `${title} - ${appTitle}` : appTitle; } +function getRedirectPath(queryValue: unknown): string | undefined { + if (Array.isArray(queryValue)) return queryValue.find((item) => typeof item === 'string'); + return typeof queryValue === 'string' ? queryValue : undefined; +} + /** * 全局路由守卫 * 核心流程: @@ -26,8 +32,21 @@ router.beforeEach(async (to, _from, next) => { // ── /login 特殊处理 ── if (to.path === '/login') { if (auth.isLoggedIn()) { - const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore()); - next(homePath.value); + const { loadMenu, loaded, homePath, isRoutePathAvailable } = + await import('@/stores/menuStore').then((m) => m.useMenuStore()); + if (!loaded.value) { + const { loadPermissions } = await import('@/stores/permissionStore').then((m) => + m.usePermissionStore(), + ); + await Promise.all([loadMenu(), loadPermissions()]); + } + + const redirectPath = getRedirectPath(to.query.redirect); + const targetPath = + redirectPath && isRoutePathAvailable(redirectPath) + ? redirectPath + : homePath.value || '/404'; + next(targetPath); } else { next(); } @@ -36,7 +55,7 @@ router.beforeEach(async (to, _from, next) => { // ── 未登录 → 跳转登录页 ── if (!auth.isLoggedIn()) { - next('/login'); + next({ path: '/login', query: { redirect: to.fullPath } }); return; } @@ -68,6 +87,9 @@ router.afterEach((to) => { const componentName = (to.meta.componentName as string) || (to.name as string); if (!componentName) return; + const { isRoutePathAvailable } = useMenuStore(); + if (!isRoutePathAvailable(to.path)) return; + const { addTab } = useTabsStore(); addTab({ path: to.path, diff --git a/src/stores/menuStore.ts b/src/stores/menuStore.ts index 3cc7b6a..8431370 100644 --- a/src/stores/menuStore.ts +++ b/src/stores/menuStore.ts @@ -54,7 +54,7 @@ const state = reactive({ menuTree: [], menuItems: [], loaded: false, - homePath: '/dashboard', + homePath: '', }); // ============================================================ @@ -69,6 +69,13 @@ function getEnabledChildren(node: MenuNode): MenuNode[] { return node.children.filter((child) => !child.disabled && !child.externalLink); } +function normalizeRoutePath(path?: string): string { + if (!path) return ''; + const purePath = path.split(/[?#]/)[0] || ''; + if (!purePath || purePath === '/') return purePath; + return purePath.startsWith('/') ? purePath : `/${purePath}`; +} + /** * 将后端 MenuNode 递归转为 antd Menu 的 items 格式 * @@ -186,15 +193,35 @@ function getFirstMenuPath(nodes: MenuNode[]): string { if (node.disabled || node.externalLink) continue; const enabledChildren = getEnabledChildren(node); - if (!node.hideInMenu && enabledChildren.length === 0) { - return '/' + node.path; + if (!node.hideInMenu && node.component) { + return normalizeRoutePath(node.path); } if (enabledChildren.length > 0) { const childPath = getFirstMenuPath(enabledChildren); if (childPath) return childPath; } } - return '/dashboard'; + return ''; +} + +function hasRoutePath(nodes: MenuNode[], targetPath: string): boolean { + const normalizedTarget = normalizeRoutePath(targetPath); + if (!normalizedTarget) return false; + + for (const node of nodes) { + if (node.disabled || node.externalLink) continue; + + const currentPath = normalizeRoutePath(node.path); + if (currentPath === normalizedTarget && node.component) { + return true; + } + + if (node.children?.length && hasRoutePath(node.children, normalizedTarget)) { + return true; + } + } + + return false; } /** @@ -230,7 +257,7 @@ function applyMenuTree(menuTree: MenuNode[]) { }); // 默认重定向到首页 - router.addRoute('BasicLayout', { path: '', redirect: state.homePath }); + router.addRoute('BasicLayout', { path: '', redirect: state.homePath || '/404' }); } // ============================================================ @@ -258,7 +285,11 @@ export function useMenuStore() { state.menuTree = []; state.menuItems = []; state.loaded = false; - state.homePath = '/dashboard'; + state.homePath = ''; + }; + + const isRoutePathAvailable = (path?: string) => { + return hasRoutePath(state.menuTree, path || ''); }; return { @@ -269,5 +300,6 @@ export function useMenuStore() { homePath: computed(() => state.homePath), loadMenu, clearMenu, + isRoutePathAvailable, }; } diff --git a/src/stores/tabsStore.ts b/src/stores/tabsStore.ts index 88d376d..d9c6bcc 100644 --- a/src/stores/tabsStore.ts +++ b/src/stores/tabsStore.ts @@ -1,5 +1,6 @@ import { reactive, computed, toRefs } from 'vue'; import router from '@/router'; +import { useMenuStore } from '@/stores/menuStore'; import type { TabView } from '@/types'; export type { TabView }; @@ -31,6 +32,9 @@ export function useTabsStore() { const addTab = (view: TabView): void => { if (!view || !view.path || !view.name) return; + const { isRoutePathAvailable } = useMenuStore(); + if (!isRoutePathAvailable(view.path)) return; + if (!state.tabs.some((t) => t.path === view.path)) { state.tabs.push(view); }