Greasy Fork

Greasy Fork is available in English.

Vue路由一键切换助手

在Vue项目中生成可拖拽菜单,显示所有路由,支持快速跳转

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Vue路由一键切换助手
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  在Vue项目中生成可拖拽菜单,显示所有路由,支持快速跳转
// @icon         https://cn.vuejs.org/logo.svg
// @author       Fairly
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 等待Vue应用挂载并获取router实例
    let routerInstance = null;
    let vueApp = null;

    // 检测Vue路由的方法(支持Vue2和Vue3)
    function findVueRouter() {
        // 通过DOM元素获取Vue实例(Vue2)
        const appElement = document.getElementById('app') || document.querySelector('#app');
        if (appElement && appElement.__vue__) {
            const vueInstance = appElement.__vue__;
            if (vueInstance.$router) {
                routerInstance = vueInstance.$router;
                vueApp = vueInstance;
                return true;
            }
        }

        // 尝试获取全局Vue实例(某些Vue2项目)
        if (window.vueDevtools && window.vueDevtools.apps && window.vueDevtools.apps[0]) {
            const app = window.vueDevtools.apps[0];
            if (app.$router) {
                routerInstance = app.$router;
                vueApp = app;
                return true;
            }
        }

        // 检测Vue3的__vue_app__属性
        if (appElement && appElement.__vue_app__) {
            const app = appElement.__vue_app__;
            // Vue3的router通常挂在config.globalProperties上
            if (app.config && app.config.globalProperties && app.config.globalProperties.$router) {
                routerInstance = app.config.globalProperties.$router;
                vueApp = app;
                return true;
            }
        }

        // 遍历window对象查找可能的router实例(兜底方案)
        for (let key in window) {
            try {
                if (window[key] && window[key].$router) {
                    routerInstance = window[key].$router;
                    vueApp = window[key];
                    return true;
                }
            } catch(e) {}
        }

        return false;
    }

    // 获取所有路由(递归解析路由配置)
    function getAllRoutes(routes, basePath = '') {
        let routeList = [];
        if (!routes) return routeList;

        for (let route of routes) {
            // 构建完整路径
            let fullPath = basePath + (route.path || '');
            // 处理动态路由参数(保留占位符便于识别,但跳转时动态填充暂时留空)
            let displayPath = fullPath;
            let displayName = route.name || route.path || '未命名路由';

            // 存储路由信息
            routeList.push({
                path: fullPath,
                name: displayName,
                originalName: route.name || ''
            });

            // 递归处理子路由
            if (route.children && route.children.length > 0) {
                let childRoutes = getAllRoutes(route.children, fullPath + (fullPath.endsWith('/') ? '' : '/'));
                routeList.push(...childRoutes);
            }
        }
        return routeList;
    }

    // 刷新路由列表UI
    function buildDropdownContent() {
        if (!routerInstance || !routerInstance.options || !routerInstance.options.routes) {
            return '<div style="padding: 10px; color: #999;">未检测到路由配置</div>';
        }

        const routes = getAllRoutes(routerInstance.options.routes);
        if (routes.length === 0) {
            return '<div style="padding: 10px; color: #999;">暂无路由信息</div>';
        }

        let html = '<div style="max-height: 400px; overflow-y: auto;">';
        routes.forEach(route => {
            const currentPath = routerInstance.currentRoute?.path || window.location.hash.replace('#', '') || window.location.pathname;
            const isActive = (currentPath === route.path) || (currentPath === route.path + '/');
            const activeStyle = isActive ? 'background: #e6f7ff; color: #1890ff;' : '';
            html += `
                <div class="vue-route-item" data-path="${escapeHtml(route.path)}" style="padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f0f0f0; font-size: 14px; ${activeStyle}">
                    <div style="font-weight: 500;">${escapeHtml(route.name)}</div>
                    <div style="font-size: 12px; color: #888;">${escapeHtml(route.path || '/')}</div>
                </div>
            `;
        });
        html += '</div>';
        return html;
    }

    // 简单的防XSS
    function escapeHtml(str) {
        if (!str) return '';
        return str.replace(/[&<>]/g, function(m) {
            if (m === '&') return '&amp;';
            if (m === '<') return '&lt;';
            if (m === '>') return '&gt;';
            return m;
        }).replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, function(c) {
            return c;
        });
    }

    // 执行路由跳转
    function jumpToRoute(path) {
        if (!routerInstance) {
            console.warn('路由实例未找到');
            return;
        }
        try {
            // 移除可能开头的#或多余字符
            let cleanPath = path;
            if (cleanPath.startsWith('#')) cleanPath = cleanPath.slice(1);
            // 使用router.push进行跳转
            routerInstance.push(cleanPath).catch(err => {
                // 忽略重复路由导航错误
                if (err.name !== 'NavigationDuplicated') {
                    console.warn('路由跳转失败:', err);
                }
            });
        } catch(e) {
            console.warn('跳转异常:', e);
        }
        // 关闭下拉菜单
        const dropdown = document.querySelector('.vue-route-dropdown');
        if (dropdown) dropdown.style.display = 'none';
    }

    // 创建可拖拽菜单UI
    function createMenuUI() {
        // 移除已存在的菜单
        const existingMenu = document.getElementById('vue-route-helper-menu');
        if (existingMenu) existingMenu.remove();

        // 创建容器
        const menuContainer = document.createElement('div');
        menuContainer.id = 'vue-route-helper-menu';
        menuContainer.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            z-index: 99999;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            user-select: none;
        `;

        // 触发按钮
        const triggerBtn = document.createElement('div');
        triggerBtn.className = 'vue-route-trigger';
        triggerBtn.innerHTML = '🗺️ 路由';
        triggerBtn.style.cssText = `
            background: #1890ff;
            color: white;
            padding: 8px 16px;
            border-radius: 24px;
            cursor: pointer;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
            font-size: 14px;
            font-weight: 500;
            transition: all 0.3s;
            text-align: center;
            backdrop-filter: blur(4px);
            background-color: rgba(24, 144, 255, 0.9);
        `;

        // 下拉内容
        const dropdown = document.createElement('div');
        dropdown.className = 'vue-route-dropdown';
        dropdown.style.cssText = `
            position: absolute;
            top: 100%;
            right: 0;
            margin-top: 8px;
            background: white;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            min-width: 260px;
            max-width: 360px;
            overflow: hidden;
            display: none;
            z-index: 100000;
            border: 1px solid #e8e8e8;
        `;

        // 加载路由列表
        function updateDropdownContent() {
            dropdown.innerHTML = buildDropdownContent();
            // 绑定点击事件
            setTimeout(() => {
                const items = dropdown.querySelectorAll('.vue-route-item');
                items.forEach(item => {
                    const path = item.getAttribute('data-path');
                    if (path) {
                        item.addEventListener('click', (e) => {
                            e.stopPropagation();
                            jumpToRoute(path);
                        });
                    }
                });
            }, 10);
        }

        updateDropdownContent();

        // 切换下拉显示/隐藏
        triggerBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            const isVisible = dropdown.style.display === 'block';
            if (!isVisible) {
                // 重新获取最新的路由列表(路由可能动态变化)
                if (findVueRouter()) {
                    updateDropdownContent();
                }
                dropdown.style.display = 'block';
            } else {
                dropdown.style.display = 'none';
            }
        });

        // 点击页面其他地方关闭下拉
        document.addEventListener('click', function closeDropdown(e) {
            if (!menuContainer.contains(e.target)) {
                dropdown.style.display = 'none';
            }
        });

        menuContainer.appendChild(triggerBtn);
        menuContainer.appendChild(dropdown);

        document.body.appendChild(menuContainer);

        // 实现拖拽功能
        let isDragging = false;
        let startX, startY, startRight, startTop;

        triggerBtn.addEventListener('mousedown', (e) => {
            if (e.button !== 0) return; // 只响应左键
            e.preventDefault();
            e.stopPropagation();

            isDragging = true;
            const rect = menuContainer.getBoundingClientRect();
            startX = e.clientX;
            startY = e.clientY;
            startRight = window.innerWidth - rect.right;
            startTop = rect.top;

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);

            // 拖拽时临时改变样式
            triggerBtn.style.cursor = 'grabbing';
            menuContainer.style.transition = 'none';
        });

        function onMouseMove(e) {
            if (!isDragging) return;
            e.preventDefault();

            let dx = e.clientX - startX;
            let dy = e.clientY - startY;

            let newRight = startRight - dx;
            let newTop = startTop + dy;

            // 边界限制,避免拖出屏幕
            const maxRight = window.innerWidth - menuContainer.offsetWidth;
            const minRight = 0;
            const minTop = 0;
            const maxTop = window.innerHeight - menuContainer.offsetHeight;

            newRight = Math.min(maxRight, Math.max(minRight, newRight));
            newTop = Math.min(maxTop, Math.max(minTop, newTop));

            menuContainer.style.right = newRight + 'px';
            menuContainer.style.top = newTop + 'px';
            menuContainer.style.left = 'auto';
            menuContainer.style.bottom = 'auto';
        }

        function onMouseUp() {
            isDragging = false;
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
            triggerBtn.style.cursor = 'pointer';
            menuContainer.style.transition = '';
        }

        // 防止拖拽时触发按钮点击
        let hasMoved = false;
        triggerBtn.addEventListener('click', (e) => {
            if (hasMoved) {
                hasMoved = false;
                e.stopPropagation();
                return;
            }
        });
        // 改进:监听拖拽移动标志
        const originalMouseDown = triggerBtn.onmousedown;
        triggerBtn.addEventListener('mousemove', () => {
            if (isDragging) hasMoved = true;
        });
    }

    // 周期性检测Vue路由,直到获取成功
    let retryCount = 0;
    function initHelper() {
        if (findVueRouter() && routerInstance) {
            createMenuUI();
            // 监听路由变化,可选:自动刷新菜单内容(当下拉打开时刷新即可,不做额外复杂逻辑)
            // 但为了更加健壮,当切换路由后重新获取路由配置,可以通过拦截或观察
            // 简化处理: 每次打开下拉时重新获取路由列表已经在triggerBtn的click里调用了updateDropdownContent
        } else {
            retryCount++;
            if (retryCount < 20) { // 最多重试20次,约10秒
                setTimeout(initHelper, 500);
            } else {
                console.warn('Vue路由一键切换助手: 未检测到Vue路由实例,请确保页面为Vue应用且路由已初始化');
                // 创造降级菜单,提示未检测到
                const failMenu = document.createElement('div');
                failMenu.id = 'vue-route-helper-menu';
                failMenu.style.cssText = 'position:fixed;top:20px;right:20px;z-index:99999;background:#ff4d4f;color:white;padding:6px 12px;border-radius:20px;font-size:12px;cursor:pointer;';
                failMenu.innerText = '⚠️ 未检测到Vue路由';
                failMenu.onclick = () => { failMenu.remove(); };
                document.body.appendChild(failMenu);
                setTimeout(() => { if(failMenu.parentNode) failMenu.remove(); }, 5000);
            }
        }
    }

    // 页面加载完成后启动检测
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initHelper);
    } else {
        initHelper();
    }

    // 监听SPA路由变化,如果页面发生了完全的路由切换导致DOM重建,简单重新检测菜单是否存在
    let lastUrl = location.href;
    setInterval(() => {
        if (lastUrl !== location.href) {
            lastUrl = location.href;
            // 页面URL变化后,可能Vue实例重新挂载,重新检测路由实例
            if (!document.getElementById('vue-route-helper-menu')) {
                if (findVueRouter()) {
                    createMenuUI();
                } else {
                    // 延时重试
                    setTimeout(() => {
                        if (findVueRouter() && !document.getElementById('vue-route-helper-menu')) {
                            createMenuUI();
                        }
                    }, 1000);
                }
            } else {
                // 如果菜单已存在但是router实例变化,更新内部引用
                findVueRouter();
            }
        }
    }, 800);
})();