fix: ref写法优化 路由跳转逻辑优化 部分样式优化

This commit is contained in:
ZhuRui
2026-07-29 09:32:14 +08:00
parent a4dab891cf
commit 8439f64c40
11 changed files with 145 additions and 69 deletions
+11 -9
View File
@@ -3,13 +3,14 @@ import {
nextTick, nextTick,
onBeforeUnmount, onBeforeUnmount,
onMounted, onMounted,
type PropType,
ref, ref,
type PropType,
watch, watch,
} from 'vue'; } from 'vue';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue'; import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
import type { TabView } from '@/types'; import type { TabView } from '@/types';
import { useState } from '@/hooks';
import { renderRouteIcon } from '@/utils/routeIcon'; import { renderRouteIcon } from '@/utils/routeIcon';
import styles from './PageTabs.module.less'; import styles from './PageTabs.module.less';
@@ -37,24 +38,24 @@ const PageTabs = defineComponent({
setup(props) { setup(props) {
const scrollRef = ref<HTMLElement>(); const scrollRef = ref<HTMLElement>();
const tabItemRefs = ref<Record<string, HTMLElement | undefined>>({}); const tabItemRefs = ref<Record<string, HTMLElement | undefined>>({});
const showNavLeft = ref(false); const [showNavLeft, setShowNavLeft] = useState(false);
const showNavRight = ref(false); const [showNavRight, setShowNavRight] = useState(false);
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
const updateScrollState = () => { const updateScrollState = () => {
const el = scrollRef.value; const el = scrollRef.value;
if (!el) { if (!el) {
showNavLeft.value = false; setShowNavLeft(false);
showNavRight.value = false; setShowNavRight(false);
return; return;
} }
const { scrollLeft, scrollWidth, clientWidth } = el; const { scrollLeft, scrollWidth, clientWidth } = el;
const canScroll = scrollWidth - clientWidth > 1; const canScroll = scrollWidth - clientWidth > 1;
showNavLeft.value = canScroll && scrollLeft > 1; setShowNavLeft(canScroll && scrollLeft > 1);
showNavRight.value = canScroll && scrollLeft + clientWidth < scrollWidth - 1; setShowNavRight(canScroll && scrollLeft + clientWidth < scrollWidth - 1);
}; };
const scrollByStep = (direction: -1 | 1) => { const scrollByStep = (direction: -1 | 1) => {
@@ -92,9 +93,10 @@ const PageTabs = defineComponent({
const setTabItemRef = (path: string, el: Element | null) => { const setTabItemRef = (path: string, el: Element | null) => {
if (el) { if (el) {
tabItemRefs.value[path] = el as HTMLElement; tabItemRefs.value[path] = el as HTMLElement;
} else { return;
delete tabItemRefs.value[path];
} }
delete tabItemRefs.value[path];
}; };
const handleClick = (key: string) => { const handleClick = (key: string) => {
+10 -4
View File
@@ -3,6 +3,7 @@ import { useState } from './useState';
import { useEffect } from './useEffect'; import { useEffect } from './useEffect';
import { useMenuStore } from '@/stores/menuStore'; import { useMenuStore } from '@/stores/menuStore';
import { usePermissionStore } from '@/stores/permissionStore'; import { usePermissionStore } from '@/stores/permissionStore';
import { useTabsStore } from '@/stores/tabsStore';
import router from '@/router'; import router from '@/router';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN'; const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
@@ -24,17 +25,20 @@ function createAuth() {
/** /**
* 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页 * 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页
*/ */
const login = async (newToken: string) => { const login = async (newToken: string, targetPath?: string) => {
setToken(newToken); setToken(newToken);
const { loadMenu, homePath } = useMenuStore(); const { loadMenu, homePath, isRoutePathAvailable } = useMenuStore();
const { loadPermissions } = usePermissionStore(); const { loadPermissions } = usePermissionStore();
// 并行拉取菜单和权限 // 并行拉取菜单和权限
await Promise.all([loadMenu(), loadPermissions()]); await Promise.all([loadMenu(), loadPermissions()]);
// 跳转到首页(动态路由已注册完成) const nextPath =
router.push(homePath.value); targetPath && isRoutePathAvailable(targetPath) ? targetPath : homePath.value || '/404';
// 动态路由注册完成后,再跳转到有效目标页
router.push(nextPath);
}; };
/** /**
@@ -45,7 +49,9 @@ function createAuth() {
const { clearMenu } = useMenuStore(); const { clearMenu } = useMenuStore();
const { clearPermissions } = usePermissionStore(); const { clearPermissions } = usePermissionStore();
const { clearTabs } = useTabsStore();
clearTabs();
clearMenu(); clearMenu();
clearPermissions(); clearPermissions();
+18 -14
View File
@@ -79,20 +79,6 @@
} }
} }
.menuItem {
display: inline-flex;
align-items: center;
gap: 8px;
:global(.anticon) {
font-size: 14px;
}
}
.menuItemDanger {
color: #ff4d4f;
}
.content { .content {
margin: 16px; margin: 16px;
border-radius: 8px; border-radius: 8px;
@@ -135,3 +121,21 @@
border-top: 1px solid #f0f0f0; 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;
}
+3 -3
View File
@@ -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 type { CSSProperties } from 'vue';
import { useRouter, useRoute, RouterView } from 'vue-router'; 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';
@@ -74,7 +74,7 @@ export default defineComponent({
setup() { setup() {
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const collapsed = ref(false); const [collapsed, setCollapsed] = useState(false);
const { menuItems } = useMenuStore(); const { menuItems } = useMenuStore();
const { phone } = useUserStore(); const { phone } = useUserStore();
@@ -90,7 +90,7 @@ export default defineComponent({
}; };
const toggleCollapsed = () => { const toggleCollapsed = () => {
collapsed.value = !collapsed.value; setCollapsed(!collapsed.value);
}; };
const activeTabKey = computed(() => route.path); const activeTabKey = computed(() => route.path);
@@ -1,5 +1,5 @@
import { defineComponent, ref, reactive } from 'vue'; import { defineComponent, ref, reactive } from 'vue';
import { useEffect } from '@/hooks'; import { useEffect, useState } from '@/hooks';
import { import {
Modal, Modal,
Form, Form,
@@ -82,9 +82,9 @@ export default defineComponent({
const formData = reactive(getDefaultForm()); const formData = reactive(getDefaultForm());
/** 裁剪弹窗状态 */ /** 裁剪弹窗状态 */
const cropperVisible = ref(false); const [cropperVisible, setCropperVisible] = useState(false);
/** 待裁剪图片的本地预览 URL */ /** 待裁剪图片的本地预览 URL */
const uploadImageUrl = ref(''); const [uploadImageUrl, setUploadImageUrl] = useState('');
/** 根据 record 初始化表单 */ /** 根据 record 初始化表单 */
const initFormFromRecord = (record: any) => { const initFormFromRecord = (record: any) => {
@@ -124,8 +124,8 @@ export default defineComponent({
useEffect(() => { useEffect(() => {
if (props.visible) { if (props.visible) {
initFormFromRecord(props.record); initFormFromRecord(props.record);
cropperVisible.value = false; setCropperVisible(false);
uploadImageUrl.value = ''; setUploadImageUrl('');
setTimeout(() => formRef.value?.clearValidate(), 0); setTimeout(() => formRef.value?.clearValidate(), 0);
} }
}, [() => props.visible]); }, [() => props.visible]);
@@ -167,13 +167,13 @@ export default defineComponent({
} }
// 生成本地预览 URL,打开裁剪弹窗 // 生成本地预览 URL,打开裁剪弹窗
uploadImageUrl.value = URL.createObjectURL(file); setUploadImageUrl(URL.createObjectURL(file));
cropperVisible.value = true; setCropperVisible(true);
}; };
/** 裁剪确认回调:将裁剪后的图片上传到 OSS */ /** 裁剪确认回调:将裁剪后的图片上传到 OSS */
const handleCropConfirm = async (dataUrl: string) => { const handleCropConfirm = async (dataUrl: string) => {
cropperVisible.value = false; setCropperVisible(false);
// 释放旧的本地预览 // 释放旧的本地预览
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) { if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
@@ -206,16 +206,16 @@ export default defineComponent({
if (uploadImageUrl.value) { if (uploadImageUrl.value) {
URL.revokeObjectURL(uploadImageUrl.value); URL.revokeObjectURL(uploadImageUrl.value);
} }
uploadImageUrl.value = ''; setUploadImageUrl('');
}; };
/** 裁剪弹窗取消 */ /** 裁剪弹窗取消 */
const onCropperCancel = () => { const onCropperCancel = () => {
cropperVisible.value = false; setCropperVisible(false);
if (uploadImageUrl.value) { if (uploadImageUrl.value) {
URL.revokeObjectURL(uploadImageUrl.value); URL.revokeObjectURL(uploadImageUrl.value);
} }
uploadImageUrl.value = ''; setUploadImageUrl('');
}; };
/** 删除已上传图片 */ /** 删除已上传图片 */
@@ -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 { Modal, Table, Button, Pagination, Image, Space } from 'ant-design-vue';
import { StatusTag, type StatusTagTone } from '@/components'; import { StatusTag, type StatusTagTone } from '@/components';
import { useState } from '@/hooks';
import styles from './OrderDetailModal.module.less'; import styles from './OrderDetailModal.module.less';
interface OrderDetailModalProps { interface OrderDetailModalProps {
@@ -84,8 +85,8 @@ export default defineComponent({
onReRefund: { type: Function, required: true }, onReRefund: { type: Function, required: true },
}, },
setup(props: OrderDetailModalProps) { setup(props: OrderDetailModalProps) {
const refundPage = ref<number>(1); const [refundPage, setRefundPage] = useState(1);
const refundPageSize = ref<number>(2); const [refundPageSize, setRefundPageSize] = useState(2);
/** 退款记录 */ /** 退款记录 */
const refundRecords = computed<any[]>(() => props.record?.refundRecords || []); const refundRecords = computed<any[]>(() => props.record?.refundRecords || []);
@@ -105,8 +106,8 @@ export default defineComponent({
); );
const handleRefundPageChange = (page: number, pageSize: number) => { const handleRefundPageChange = (page: number, pageSize: number) => {
refundPage.value = page; setRefundPage(page);
refundPageSize.value = pageSize; setRefundPageSize(pageSize);
}; };
const handleConfirm = () => { const handleConfirm = () => {
+13 -8
View File
@@ -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 { Button, Checkbox, Form, FormItem, Input, message } from 'ant-design-vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'; import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { auth } from '@/hooks/useAuth'; import { auth } from '@/hooks/useAuth';
import { useEffect } from '@/hooks/useEffect'; import { useEffect, useState } from '@/hooks';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import styles from './index.module.less'; import styles from './index.module.less';
@@ -14,8 +15,9 @@ interface LoginForm {
export default defineComponent({ export default defineComponent({
name: 'LoginPage', name: 'LoginPage',
setup() { setup() {
const loading = ref(false); const route = useRoute();
const rememberMe = ref(false); const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const form = reactive<LoginForm>({ const form = reactive<LoginForm>({
username: '', username: '',
@@ -29,7 +31,7 @@ export default defineComponent({
const data = JSON.parse(saved); const data = JSON.parse(saved);
form.username = data.username || ''; form.username = data.username || '';
form.password = data.password || ''; form.password = data.password || '';
rememberMe.value = true; setRememberMe(true);
} catch { } catch {
localStorage.removeItem('cpms_login_remember'); localStorage.removeItem('cpms_login_remember');
} }
@@ -47,7 +49,7 @@ export default defineComponent({
return; return;
} }
loading.value = true; setLoading(true);
try { try {
await new Promise((r) => setTimeout(r, 600)); await new Promise((r) => setTimeout(r, 600));
@@ -57,7 +59,10 @@ export default defineComponent({
const { setUser } = useUserStore(); const { setUser } = useUserStore();
setUser({ phone: form.username, nickname: form.username, token }); 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('登录成功'); message.success('登录成功');
if (rememberMe.value) { if (rememberMe.value) {
@@ -74,7 +79,7 @@ export default defineComponent({
} catch (err) { } catch (err) {
message.error('登录失败,请重试'); message.error('登录失败,请重试');
} finally { } finally {
loading.value = false; setLoading(false);
} }
}; };
@@ -1,5 +1,5 @@
import { defineComponent, ref, reactive } from 'vue'; 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 { Modal, Form, Input, Select, Button } from 'ant-design-vue';
import { import {
ROLE_FORM_OPTIONS, ROLE_FORM_OPTIONS,
@@ -43,14 +43,14 @@ export default defineComponent({
const formData = reactive(getDefaultForm()); const formData = reactive(getDefaultForm());
/** 编辑模式下是否处于"重置密码"状态 */ /** 编辑模式下是否处于"重置密码"状态 */
const isResetting = ref(false); const [isResetting, setIsResetting] = useState(false);
/** 根据 record 初始化表单 */ /** 根据 record 初始化表单 */
const initFormFromRecord = (record: any) => { const initFormFromRecord = (record: any) => {
const fresh = getDefaultForm(); const fresh = getDefaultForm();
if (!record) { if (!record) {
Object.assign(formData, fresh); Object.assign(formData, fresh);
isResetting.value = false; setIsResetting(false);
return; return;
} }
Object.assign(formData, { Object.assign(formData, {
@@ -59,21 +59,21 @@ export default defineComponent({
phone: record.phone || '', phone: record.phone || '',
role: record.role || '', role: record.role || '',
}); });
isResetting.value = false; setIsResetting(false);
}; };
/** 监听 visible 变化重置表单 */ /** 监听 visible 变化重置表单 */
useEffect(() => { useEffect(() => {
if (props.visible) { if (props.visible) {
initFormFromRecord(props.record); initFormFromRecord(props.record);
isResetting.value = false; setIsResetting(false);
setTimeout(() => formRef.value?.clearValidate(), 0); setTimeout(() => formRef.value?.clearValidate(), 0);
} }
}, [() => props.visible]); }, [() => props.visible]);
/** 点击"重置密码" */ /** 点击"重置密码" */
const handleResetPassword = () => { const handleResetPassword = () => {
isResetting.value = true; setIsResetting(true);
formData.password = ''; formData.password = '';
formData.confirmPassword = ''; formData.confirmPassword = '';
}; };
+25 -3
View File
@@ -1,6 +1,7 @@
import router from '@/router'; import router from '@/router';
import { auth } from '@/hooks/useAuth'; import { auth } from '@/hooks/useAuth';
import { useTabsStore } from '@/stores/tabsStore'; import { useTabsStore } from '@/stores/tabsStore';
import { useMenuStore } from '@/stores/menuStore';
const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台'; const appTitle = import.meta.env.VITE_APP_TITLE || 'CPMS 运营平台';
@@ -9,6 +10,11 @@ function updateDocumentTitle(title?: string) {
document.title = title ? `${title} - ${appTitle}` : appTitle; 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 特殊处理 ── // ── /login 特殊处理 ──
if (to.path === '/login') { if (to.path === '/login') {
if (auth.isLoggedIn()) { if (auth.isLoggedIn()) {
const { homePath } = await import('@/stores/menuStore').then((m) => m.useMenuStore()); const { loadMenu, loaded, homePath, isRoutePathAvailable } =
next(homePath.value); 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 { } else {
next(); next();
} }
@@ -36,7 +55,7 @@ router.beforeEach(async (to, _from, next) => {
// ── 未登录 → 跳转登录页 ── // ── 未登录 → 跳转登录页 ──
if (!auth.isLoggedIn()) { if (!auth.isLoggedIn()) {
next('/login'); next({ path: '/login', query: { redirect: to.fullPath } });
return; return;
} }
@@ -68,6 +87,9 @@ router.afterEach((to) => {
const componentName = (to.meta.componentName as string) || (to.name as string); const componentName = (to.meta.componentName as string) || (to.name as string);
if (!componentName) return; if (!componentName) return;
const { isRoutePathAvailable } = useMenuStore();
if (!isRoutePathAvailable(to.path)) return;
const { addTab } = useTabsStore(); const { addTab } = useTabsStore();
addTab({ addTab({
path: to.path, path: to.path,
+38 -6
View File
@@ -54,7 +54,7 @@ const state = reactive<MenuState>({
menuTree: [], menuTree: [],
menuItems: [], menuItems: [],
loaded: false, loaded: false,
homePath: '/dashboard', homePath: '',
}); });
// ============================================================ // ============================================================
@@ -69,6 +69,13 @@ function getEnabledChildren(node: MenuNode): MenuNode[] {
return node.children.filter((child) => !child.disabled && !child.externalLink); 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 格式 * 将后端 MenuNode 递归转为 antd Menu 的 items 格式
* *
@@ -186,15 +193,35 @@ function getFirstMenuPath(nodes: MenuNode[]): string {
if (node.disabled || node.externalLink) continue; if (node.disabled || node.externalLink) continue;
const enabledChildren = getEnabledChildren(node); const enabledChildren = getEnabledChildren(node);
if (!node.hideInMenu && enabledChildren.length === 0) { if (!node.hideInMenu && node.component) {
return '/' + node.path; return normalizeRoutePath(node.path);
} }
if (enabledChildren.length > 0) { if (enabledChildren.length > 0) {
const childPath = getFirstMenuPath(enabledChildren); const childPath = getFirstMenuPath(enabledChildren);
if (childPath) return childPath; 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.menuTree = [];
state.menuItems = []; state.menuItems = [];
state.loaded = false; state.loaded = false;
state.homePath = '/dashboard'; state.homePath = '';
};
const isRoutePathAvailable = (path?: string) => {
return hasRoutePath(state.menuTree, path || '');
}; };
return { return {
@@ -269,5 +300,6 @@ export function useMenuStore() {
homePath: computed(() => state.homePath), homePath: computed(() => state.homePath),
loadMenu, loadMenu,
clearMenu, clearMenu,
isRoutePathAvailable,
}; };
} }
+4
View File
@@ -1,5 +1,6 @@
import { reactive, computed, toRefs } from 'vue'; import { reactive, computed, toRefs } from 'vue';
import router from '@/router'; import router from '@/router';
import { useMenuStore } from '@/stores/menuStore';
import type { TabView } from '@/types'; import type { TabView } from '@/types';
export type { TabView }; export type { TabView };
@@ -31,6 +32,9 @@ export function useTabsStore() {
const addTab = (view: TabView): void => { const addTab = (view: TabView): void => {
if (!view || !view.path || !view.name) return; if (!view || !view.path || !view.name) return;
const { isRoutePathAvailable } = useMenuStore();
if (!isRoutePathAvailable(view.path)) return;
if (!state.tabs.some((t) => t.path === view.path)) { if (!state.tabs.some((t) => t.path === view.path)) {
state.tabs.push(view); state.tabs.push(view);
} }