背景

开发习惯时使用超级管理员登录系统,超级管理员测试无法复现权限问题;业务涉及审批流场景时,也需要不同角色的用户参与审批。能否通过编程的方式解决此问题?答案是肯定的,并且很简单------只需对服务器签发的 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 || "请求异常,请检查后端地址是否正确", '油猴插件');
});
}
总结
阅读油猴脚本文档语法时,太注重细节浪费不少时间 ,可以根据需求去推断涉及哪些知识点(谋而后定,按图索骥😓)。