用油猴脚本实现用户身份快速切换

背景

开发习惯时使用超级管理员登录系统,超级管理员测试无法复现权限问题;业务涉及审批流场景时,也需要不同角色的用户参与审批。能否通过编程的方式解决此问题?答案是肯定的,并且很简单------只需对服务器签发的 token 做一个 replace 即可。

实现思路

起初想到:在页面固定区域放一个用户列表下拉框,选项里带上当前用户的 token 和身份信息------实现起来确实简单。

但这段代码只是给开发/测试用的,生产环境绝不允许跑。于是第一反应是拿"系统环境变量"做开关,可转念一想,只要代码踏进正式环境,哪怕再小的判断也可能被反向利用,风险太大,直接 pass。

以前接触过油猴脚本:它能把脚本动态注入页面,与页内代码完全解耦;用户只有主动安装脚本,才会看到那个"用户列表下拉框"。既隔离了正式环境,又省得在仓库里留任何调试尾巴------正好。

实现效果

具体代码

油猴脚本学习成本低://@grant引入相关工具函数,//requeire 引入js资源,//resource引入css资源,//@match脚本生效网址,//@namespage脚本唯一标识等.....

JSON格式配置油猴脚本依赖数据,脚本注入系统需要提供挂载容器 "selector":"#account"下拉框才能完成挂载。 更多配置如下:

markdown 复制代码
```JSON
{
    "backend": "http://193.169.41.307:8080/auth/login", //获取token接口
    "tokenKey": "access_token", //token的key
    "storeKey":"Admin-token",  //本地存储token的key
    "storeType":"store", //值为cookie | store  
    "selector": "#account", //下拉框挂载位置 
    "userList": [
        {
            "id": 2, //唯一id必传
            "label": "小明(东道园管理员)",
            "data": {
                "username": "ddy_xxx",
                "password": "xxxxx"
            }
        },
        {
          "id":3,
          "label":"史龚(浦发环境管理员)",
          "data":{
            "username":"puxxx_xxx",
            "password":"xxxxxx"
          }
        }
    ]
}
ini 复制代码
```javascript
// ==UserScript==
// @name        account_switch
// @namespace   qy script
// @match       https://www.shpfyh.com/*
// @match       http://localhost:8081/*
// @version     1.0
// @grant        GM_getResourceURL
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_getResourceText
// @grant        GM_addStyle
// @grant        unsafeWindow
// @grant        GM_deleteValue
// @author       quyue
// @require      https://code.bdstatic.com/npm/jquery@3.7.1/dist/jquery.min.js
// @require      https://cdn.bootcdn.net/ajax/libs/toastr.js/2.1.4/toastr.min.js
// @resource     CSS https://cdn.bootcdn.net/ajax/libs/toastr.js/2.1.4/toastr.min.css
// @description 2025/9/28 11:06:53
// ==/UserScript==

try {
  (() => {
    'use strict';
    // 引入样式
    GM_addStyle(GM_getResourceText("CSS"));
    // 1. 建容器 + Shadow
    const host = document.createElement('div');
    const root = host.attachShadow({ mode: 'open' });
    document.body.appendChild(host);

    // 2. 把 Tailwind 扔进去
    const tailwind = document.createElement('link');
    tailwind.rel = 'stylesheet';
    tailwind.href = 'https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css';
    root.appendChild(tailwind);

    // 3. 弹框 markup(含账户信息文本域)
    root.innerHTML += `
      <style>
        /* 自定义对话框样式 */
        dialog {
          width: 50vw;
          max-width: 50vw;
          margin: auto;
          position: fixed;
          top: 30%;
          left: 50%;
          transform: translate(-50%, -50%);
          border: none;
        }
        .flex-row {
          display: flex;
          align-items: flex-start;
          gap: 1rem;
        }
        .label-container {
          min-width: 80px;
        }
        .textarea-container {
          flex: 1;
        }
      </style>
      <dialog id="dlg" class="bg-white rounded-lg shadow-xl p-6">
        <h2 class="text-lg font-bold mb-4">脚本初始化</h2>

        <!-- 备注 -->
        <div class="flex-row mb-4">
          <div class="label-container">
            <label class="block text-sm font-medium text-gray-700">配置数据: </label>
          </div>
          <div class="textarea-container">
            <textarea id="note" rows="12" placeholder="配置数据..."
                      class="w-full border border-gray-300 rounded px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-400"></textarea>
          </div>
        </div>

        <!-- 底部按钮 -->
        <div class="flex justify-end space-x-3 mt-auto">
          <button id="cancelBtn" class="px-4 py-2 bg-white text-gray-700 border border-gray-300 rounded hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent">
            取消
          </button>
          <button id="confirmBtn" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent">
            确定
          </button>
        </div>
      </dialog>`;

    // 4. 事件 & 打开
    const dlg = root.getElementById('dlg');
    const cancelBtn = root.getElementById('cancelBtn');
    const confirmBtn = root.getElementById('confirmBtn');
    const textarea = root.getElementById('note')

    cancelBtn.onclick = () => dlg.close();

    confirmBtn.onclick = () => {
      // token信息保存
      GM_setValue(location.hostname, textarea.value);
      //
      userListToSelect(textarea.value, () => {

        GM_deleteValue(location.hostname)

        dlg.showModal(); //弹框重新展示
      })
      dlg.close()
    };

    window.onload = () => {
      const result = GM_getValue(location.hostname)

      if (!result) { // 首次没有配置数据
        dlg.showModal();
      }else{
        userListToSelect(result, () => {
        GM_deleteValue(location.hostname) //! 删除无效的缓存

        textarea.value = result //! 联想传递函数的场景

        dlg.showModal(); //弹框重新展示
      })
      }
    }

  })();
} catch (error) {
  console.error(error); // 记录到控制台
  toastr.error(error.message, '油猴插件');
}

// GM函数Promise化 请求函数支持跨域
function gmRequest(options) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      ...options,
      onload: response => resolve(response),
      onerror: error => reject(error),
      ontimeout: () => reject(new Error('Request timeout'))
    });
  });
}

// 获取所有token
async function featTokenList(paramsStr, callback) {
  if (!paramsStr) {
    throw new Error("用户数据不合法, 请重新配置用户数据");
  }
  const params = JSON.parse(paramsStr); // 解析 JSON 字符串为对象数组
  const userList = params.userList ?? [];
  return Promise.all(params.userList.map(item => {
    return gmRequest({
      method: 'POST',
      url: params.backend,
      headers: {
        'Content-Type': 'application/json'
      },
      data: JSON.stringify(item.data)
    })
  })).then((res) => {

    return res.map((r, index) => {
      const item = userList[index]

      const regExp = new RegExp(
  `(?:^|[,{]\\s*)"${params.tokenKey}"\\s*:\\s*"([^"]+)"`,
  'i'          // 如果想忽略大小写
);

      const match = r.response.match(regExp);
      //token通过正则提取
      const accessToken = match ? match[1] : null;

      if (!accessToken) {
        throw new Error("用户 " + item.label + " 登录失败,请检查用户名和密码是否正确");
      }
      return {
        ...item,
        token: accessToken
      }
    })
  })
}

// 根据JSON数据创建下拉框
function createUserDropdown(userData, container) {
  // 检查参数是否有效,
  if (!userData || !Array.isArray(userData) ) {
    throw new Error("用户数据不合法, 请重新配置用户数据");
  }

  if(!container){ //不存在容器未必是错误
    throw new Error("没有找到容器元素, 请检查selector是否正确");
  }

  // 创建下拉框元素
  const select = document.createElement('select');
  // 改为内联style样式
  select.style.backgroundColor = '#ffffff';
  select.style.border = '1px solid #d1d5db';
  select.style.borderRadius = '0.375rem';
  select.style.padding = '0.5rem 0.75rem';
  select.style.outline = 'none';
  select.style.boxSizing = 'border-box';
  select.onfocus = function () {
    this.style.boxShadow = '0 0 0 2px rgba(59, 130, 246, 0.5)';
    this.style.border = '1px solid transparent';
  };
  select.onblur = function () {
    this.style.boxShadow = 'none';
    this.style.border = '1px solid #d1d5db';
  };

  // 添加默认选项
  const defaultOption = document.createElement('option');
  defaultOption.value = '';
  defaultOption.textContent = '请选择用户';
  defaultOption.selected = true;
  defaultOption.disabled = true;
  select.appendChild(defaultOption);

  // 根据用户数据创建选项
  userData.forEach(user => {
    if (user && user.id && user.label) {
      const option = document.createElement('option');
      option.value = user.id.toString();
      option.textContent = user.label;
      // 可以存储额外的用户信息在选项上
      option.dataset.username = user.data?.username || '';
      option.dataset.password = user.data?.password || '';
      option.dataset.token = user.token || '';
      select.appendChild(option);
    }
  });

  // 添加到指定容器
  container.innerHTML = ''; // 清空容器
  container.appendChild(select);

  // 返回创建的下拉框元素,以便外部可以添加事件监听等
  return select;
}

// 将数据转换为select选项
function userListToSelect(params, callback) {

  featTokenList(params, callback).then(res => {

    //! 用户名和密码错误----全部成功才可以(抛出异常让其处理)
    const userParams = JSON.parse(params);

    const container = document.querySelector(userParams.selector)

    const select = createUserDropdown(res, container)

    select && (select.onchange = async function () {

      const result = res.find((item) => item.id.toString() === this.value)

      if (result && result.token) {
        if(userParams.storeType === "cookie"){
          // 设置cookie
          document.cookie = `${userParams.storeKey}=${result.token}; path=/;`; //TODO tokenKey写死了

        }else{
          // 设置store
          localStorage.setItem(userParams.storeKey, result.token);
        }
        // 网页重新加载
        location.reload()
      }
    })
  }).catch(err => {  //HACK  try/catch 捕获不到异步的异常
    if(err.message && err.message.includes("用户")){ // 用户数据不合法
      callback && callback(); //! 自定义异常,让外层处理
    }
    console.error(err); // 记录到控制台
    toastr.error(err.message || "请求异常,请检查后端地址是否正确", '油猴插件');
  });
}

总结

阅读油猴脚本文档语法时,太注重细节浪费不少时间 ,可以根据需求去推断涉及哪些知识点(谋而后定,按图索骥😓)。

相关推荐
玲玲5122 小时前
vue3组件通信:defineEmits和defineModel
前端
温柔53292 小时前
仓颉语言异常捕获机制深度解析
java·服务器·前端
温宇飞3 小时前
ECS 系统的一种简单 TS 实现
前端
shenshizhong3 小时前
鸿蒙HDF框架源码分析
前端·源码·harmonyos
凌晨起床3 小时前
Vue3 对比 Vue2
前端·javascript
clausliang3 小时前
实现一个可插入变量的文本框
前端·vue.js
yyongsheng3 小时前
SpringBoot项目集成easy-es框架
java·服务器·前端
fruge3 小时前
前端工程化流程搭建与配置优化指南
前端
Aress"3 小时前
uniapp设置vuex公共值状态管理
javascript·vue.js·uni-app