您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
读取「权限配置表」, 批量新增/取消权限;失败行回写 Excel;SPA 兼容;Shadow DOM 样式隔离;左侧宽度拖拽;收起/展开日志与进度;未上传不显示进度/日志
// ==UserScript== // @name 【内容营销系统】权限新增与删除插件(左侧拖拽与收起/展开) // @namespace https://kol-edt.netease.com/ // @description 读取「权限配置表」, 批量新增/取消权限;失败行回写 Excel;SPA 兼容;Shadow DOM 样式隔离;左侧宽度拖拽;收起/展开日志与进度;未上传不显示进度/日志 // @version 0.2.4 // @license MIT // @match https://kol-edt.netease.com/* // @run-at document-idle // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect kol-edt.netease.com // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js // ==/UserScript== /* globals XLSX, GM_xmlhttpRequest */ (function () { 'use strict'; // ====== 路由判断(SPA)====== const PATH_TARGET = /^\/admin_manager\/system(?:\/|$)/; // ====== 业务常量 ====== const SHEET_NAME = '权限配置表'; const REQUIRED_KEYS = ['email', 'role', 'opType']; const HEADER_RULES = [ { key: 'email', re: /用户邮箱[**]?$/i }, { key: 'userName', re: /用户名称$/ }, { key: 'role', re: /角色[**]?$/ }, { key: 'productName', re: /产品名称.*[((].*下拉.*[))]$/ }, { key: 'productCode', re: /^(?:内容营销)?产品编码[**]?(?:[((]此列为公式[))])?$/ }, { key: 'opType', re: /权限操作类型$/ }, { key: 'reason', re: /操作原因$/ }, ]; const BASE = location.origin; const API = { roles: `${BASE}/auth/roles/`, users: `${BASE}/auth/users/`, projects: `${BASE}/auth/projects/`, addRoles: (userId) => `${BASE}/auth/users/${userId}/roles/`, delRole: (userRoleId) => `${BASE}/auth/user_roles/${userRoleId}/`, }; // ====== Shadow DOM 面板(样式隔离)====== let host, shadow, ui = { panel: null, uploadBtn: null, toggleBtn: null, progressWrap: null, progressBar: null, logWrap: null, logList: null, fileInput: null, resizer: null, collapsed: false, }; function buildPanel() { if (host) return; host = document.createElement('div'); host.id = 'kol-bulk-host'; host.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:2147483647;display:none;'; document.body.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { all: initial; } .panel { box-sizing: border-box; width: 360px; min-width: 320px; max-width: 60vw; background:#fff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(0,0,0,.08); display:flex; flex-direction:column; gap:10px; padding:12px; font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,"Helvetica Neue",Arial; /* 去掉原生 resize 手柄,改用左侧自定义拖拽 */ position: relative; max-height: 70vh; overflow: auto; } .panel.collapsed #progressWrap, .panel.collapsed #logWrap { display: none !important; } .header { display:flex; align-items:center; justify-content:space-between; gap:8px; } .title { margin:0; font-size:14px; line-height:20px; color:#111827; } .btn { appearance:none; padding:6px 12px; border:1px solid #d1d5db; border-radius:8px; background:#fff; color:#111827; cursor:pointer; font-size:13px; } .btn:hover{ background:#f9fafb; } .btn:active{ background:#f3f4f6; } .row { display:flex; gap:8px; align-items:center; } .progress { width:100%; height:10px; background:#f3f4f6; border:1px solid #e5e7eb; border-radius:999px; overflow:hidden; } .bar { height:100%; width:0%; background:linear-gradient(90deg,#22c55e,#3b82f6); transition: width .2s ease; } .log { border:1px dashed #e5e7eb; border-radius:8px; background:#fafafa; max-height: 38vh; overflow:auto; padding:0; } .log-list { list-style:none; margin:0; padding:6px; display:flex; flex-direction:column; gap:6px; } .item { display:grid; grid-template-columns: 6px 1fr auto; gap:8px; align-items:flex-start; background:#fff; border:1px solid #eef2f7; border-radius:8px; padding:8px 10px; font-size:12px; line-height:1.45; word-break:break-word; box-shadow:0 1px 1px rgba(17,24,39,.04); } .item .lv{ width:6px; border-radius:4px; } .item .msg{ font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; color:#374151; } .item .ts{ color:#6b7280; white-space:nowrap; font-variant-numeric: tabular-nums; } .success .lv{ background:#22c55e; } .info .lv{ background:#3b82f6; } .warn .lv{ background:#f59e0b; } .error .lv{ background:#ef4444; } .hidden{ display:none !important; } /* 左侧拖拽手柄 */ .resizer { position:absolute; left:0; top:8px; bottom:8px; width:10px; cursor: ew-resize; background: transparent; } .resizer::after{ content:''; position:absolute; left:3px; top:50%; transform:translateY(-50%); width:4px; height:28px; border-radius:2px; background:#e5e7eb; } .resizing { user-select:none; cursor: ew-resize !important; } `; shadow.appendChild(style); const wrap = document.createElement('div'); wrap.className = 'panel'; wrap.innerHTML = ` <div class="resizer" id="resizer" title="拖拽调整宽度"></div> <div class="header"> <h4 class="title">权限批量导入</h4> <div class="row"> <button class="btn" id="toggleBtn">收起</button> <button class="btn" id="uploadBtn">上传文件</button> </div> </div> <div id="progressWrap" class="hidden"><div class="progress"><div id="bar" class="bar"></div></div></div> <div id="logWrap" class="log hidden"><ul id="logList" class="log-list"></ul></div> `; shadow.appendChild(wrap); ui.panel = wrap; ui.uploadBtn = shadow.getElementById('uploadBtn'); ui.toggleBtn = shadow.getElementById('toggleBtn'); ui.progressWrap = shadow.getElementById('progressWrap'); ui.progressBar = shadow.getElementById('bar'); ui.logWrap = shadow.getElementById('logWrap'); ui.logList = shadow.getElementById('logList'); ui.resizer = shadow.getElementById('resizer'); ui.fileInput = document.createElement('input'); ui.fileInput.type = 'file'; ui.fileInput.accept = '.xlsx,.xls'; ui.fileInput.className = 'hidden'; shadow.appendChild(ui.fileInput); ui.uploadBtn.addEventListener('click', () => ui.fileInput.click()); ui.fileInput.addEventListener('change', onPickFile); ui.toggleBtn.addEventListener('click', () => { ui.collapsed = !ui.collapsed; if (ui.collapsed) ui.panel.classList.add('collapsed'); else ui.panel.classList.remove('collapsed'); ui.toggleBtn.textContent = ui.collapsed ? '展开' : '收起'; }); // 左侧拖拽宽度 initResizer(); } function initResizer(){ let startX = 0, startW = 0, minW = 320, maxW = Math.round(window.innerWidth * 0.6), active = false; const onDown = (e) => { active = true; startX = e.clientX; startW = ui.panel.getBoundingClientRect().width; document.documentElement.classList.add('resizing'); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp, { once:true }); e.preventDefault(); }; const onMove = (e) => { if (!active) return; const dx = e.clientX - startX; // 往右为正,往左为负 let w = startW - dx; // 固定在右侧,左边拖动 => 宽度 = 起始宽度 - dx w = Math.max(minW, Math.min(maxW, w)); ui.panel.style.width = w + 'px'; }; const onUp = () => { active = false; document.documentElement.classList.remove('resizing'); window.removeEventListener('pointermove', onMove); }; ui.resizer.addEventListener('pointerdown', onDown); // 双击手柄恢复默认宽度 ui.resizer.addEventListener('dblclick', () => ui.panel.style.width = '360px'); } function showPanel(){ buildPanel(); host.style.display = atTarget() ? 'block' : 'none'; } function hidePanel(){ if (host) host.style.display = 'none'; } function atTarget(){ return PATH_TARGET.test(location.pathname); } function togglePanel(){ atTarget() ? showPanel() : hidePanel(); } (function(history){ const _push = history.pushState, _replace = history.replaceState; history.pushState = function(...args){ const r = _push.apply(this, args); window.dispatchEvent(new Event('tm:urlchange')); return r; }; history.replaceState = function(...args){ const r = _replace.apply(this, args); window.dispatchEvent(new Event('tm:urlchange')); return r; }; })(window.history); window.addEventListener('popstate', () => window.dispatchEvent(new Event('tm:urlchange'))); window.addEventListener('hashchange', () => window.dispatchEvent(new Event('tm:urlchange'))); window.addEventListener('tm:urlchange', togglePanel); togglePanel(); // ====== UI 辅助 ====== function nowTs(){ return new Date().toLocaleTimeString(); } function ensureWorkAreaVisible(){ ui.progressWrap.classList.remove('hidden'); ui.logWrap.classList.remove('hidden'); } function addLog(msg, level='info'){ if (!ui.logList) return; const m = String(msg); if (m.startsWith('✅')) level = 'success'; else if (m.startsWith('❌')) level = 'error'; else if (m.startsWith('🗑️')) level = 'warn'; const li = document.createElement('li'); li.className = `item ${level}`; li.innerHTML = `<div class="lv"></div><div class="msg"></div><div class="ts">${nowTs()}</div>`; li.querySelector('.msg').textContent = m; ui.logList.appendChild(li); ui.logWrap.scrollTop = ui.logWrap.scrollHeight; } function setProgress(i, total){ const pct = Math.round((i / Math.max(total,1)) * 100); ui.progressBar.style.width = `${pct}%`; } // ====== 工具 ====== const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const ucase = (s) => (s ?? '').toString().trim().toUpperCase(); function cleanse(s){ if (s == null) return ''; let t = String(s); t = t.replace(/[\p{Z}\u200B\u200C\u200D\uFEFF]/gu, ''); t = t.replace(/*/g, '*').replace(/[()]/g, m => ({'(':'(', ')':')'}[m])); return t.trim(); } function gmRequest({ method = 'GET', url, headers = {}, data = null, anonymous = false }) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, data, anonymous, headers: Object.assign({ 'accept':'*/*','cache-control':'no-cache','pragma':'no-cache','x-dept-id':'1', 'content-type': data ? 'application/json' : undefined, }, headers), responseType: 'json', onload: (res) => { if (res.status >= 200 && res.status < 300) { resolve(typeof res.response === 'object' ? res.response : safeJSON(res.responseText)); } else { reject(new Error(`HTTP ${res.status}: ${res.responseText || res.statusText}`)); } }, onerror: (err) => reject(new Error(`Network error: ${err.error || 'unknown'}`)), ontimeout: () => reject(new Error('Request timeout')), }); }); } const safeJSON = (txt) => { try { return JSON.parse(txt); } catch { return { raw: txt }; } }; // ====== Excel 读取 ====== async function onPickFile(e) { const file = e.target.files?.[0]; if (!file) return; ensureWorkAreaVisible(); addLog(`读取文件:${file.name}`, 'info'); const arrayBuf = await file.arrayBuffer(); const wb = XLSX.read(arrayBuf, { type: 'array', cellFormula: true, cellNF: true, cellText: true }); const ws = wb.Sheets[SHEET_NAME]; if (!ws) { addLog(`❌ 未找到工作表「${SHEET_NAME}」`, 'error'); return; } const { headerRow, headersFound, colByKey } = detectHeaders(ws); if (headerRow < 0) { addLog('❌ 无法识别表头,请确认包含「用户邮箱」「角色」「权限操作类型」等关键词', 'error'); return; } addLog(`✅ 表头行:第 ${headerRow + 1} 行`); addLog(`🧭 识别到列:` + Object.entries(colByKey).map(([k,c]) => `${k}->${XLSX.utils.encode_col(c)}`).join('、'), 'info'); const range = XLSX.utils.decode_range(ws['!ref']); const rows = []; for (let r = headerRow + 1; r <= range.e.r; r++) { const raw = headersFound.map((_, c) => getCellValue(ws, r, c)); if (raw.every(v => (v ?? '').toString().trim() === '')) continue; rows.push({ raw, email: getByKey(ws, r, colByKey, 'email'), userName: getByKey(ws, r, colByKey, 'userName'), roleNameOrCode: getByKey(ws, r, colByKey, 'role'), productName: getByKey(ws, r, colByKey, 'productName'), productCode: getByKey(ws, r, colByKey, 'productCode'), opType: getByKey(ws, r, colByKey, 'opType'), reason: getByKey(ws, r, colByKey, 'reason'), }); } if (!rows.length) { addLog('❌ 没有可处理的数据行', 'error'); return; } addLog('预取系统角色与项目...', 'info'); const [roleMap, projectMap] = await Promise.all([fetchRoleMap(), fetchProjectMap()]); addLog(`角色(code)=${roleMap.get('byCode').size},角色(name)=${roleMap.get('byName').size};项目索引=${projectMap.size}`, 'info'); await processAll(rows, roleMap, projectMap, headersFound); ui.fileInput.value = ''; } function getCellValue(ws, r, c) { const addr = XLSX.utils.encode_cell({ r, c }); const cell = ws[addr]; return cell ? String(cell.v ?? cell.w ?? '').trim() : ''; } function detectHeaders(ws) { const ref = XLSX.utils.decode_range(ws['!ref']); const MAX_SCAN = Math.min(ref.s.r + 10, ref.e.r); for (let r = ref.s.r; r <= MAX_SCAN; r++) { const headersFound = []; const colByKey = {}; let hit = 0; for (let c = ref.s.c; c <= ref.e.c; c++) { const label = getCellValue(ws, r, c); headersFound.push(label); const cleaned = cleanse(label); for (const rule of HEADER_RULES) { if (colByKey[rule.key] != null) continue; if (rule.re.test(cleaned)) { colByKey[rule.key] = c; hit++; } } } const hasRequired = REQUIRED_KEYS.every(k => colByKey[k] != null); if (hit >= 4 && hasRequired) return { headerRow: r, headersFound, colByKey }; } return { headerRow: -1, headersFound: [], colByKey: {} }; } function getByKey(ws, r, colByKey, key) { const c = colByKey[key]; return c == null ? '' : getCellValue(ws, r, c); } // ====== 预取:角色、项目 ====== async function fetchRoleMap() { const json = await gmRequest({ url: API.roles }); const byCode = new Map(), byName = new Map(); for (const r of json.data || []) { if (r.code) byCode.set(ucase(r.code), r); if (r.name) byName.set(ucase(r.name), r); } return new Map([['byCode', byCode], ['byName', byName]]); } async function fetchProjectMap() { const json = await gmRequest({ url: API.projects }); const idx = new Map(); for (const p of json.data || []) { if (p.mpc_code) idx.set(ucase(p.mpc_code), p); if (p.code) idx.set(ucase(p.code), p); if (p.name) idx.set(ucase(p.name), p); } return idx; } // ====== 接口封装 ====== async function getUserByEmail(email) { const q = encodeURIComponent(String(email).trim()); const url = `${API.users}?q=${q}&page_size=10¤t_page=1`; const json = await gmRequest({ url }); return (json.data || []).find(u => (u.email || '').toLowerCase() === String(email).toLowerCase()) || null; } async function addUserRole(userId, roleId, projectId) { const body = { roles: [{ id: `new_${Date.now()}_${Math.random().toString(36).slice(2,8)}`, role_id: roleId, project_id: projectId }] }; const json = await gmRequest({ method: 'POST', url: API.addRoles(userId), data: JSON.stringify(body) }); return json?.data?.user_role_ids || []; } async function deleteUserRole(userRoleId) { await gmRequest({ method: 'DELETE', url: API.delRole(userRoleId) }); return true; } function findUserRoleId(userInfo, roleId, projectId) { const roles = userInfo?.roles || []; const match = roles.find(x => x?.role?.id === roleId && x?.project?.id === projectId); return match?.id || null; } // ====== 主流程 ====== async function processAll(rows, roleMap, projectMap, headersFound) { let failedRows = []; const byCode = roleMap.get('byCode'); const byName = roleMap.get('byName'); let ok = 0, fail = 0; setProgress(0, rows.length); for (let i = 0; i < rows.length; i++) { const row = rows[i]; setProgress(i, rows.length); try { if (!row.email) throw new Error('缺少用户邮箱'); const user = await getUserByEmail(row.email); if (!user) throw new Error('用户不存在'); if (!row.roleNameOrCode) throw new Error('缺少角色'); const roleKey = ucase(row.roleNameOrCode); const role = byCode.get(roleKey) || byName.get(roleKey); if (!role) throw new Error(`角色不存在:${row.roleNameOrCode}`); if (!row.productCode && !row.productName) throw new Error('缺少产品编码或产品名称'); const projKeys = [ucase(row.productCode), ucase(row.productName)].filter(Boolean); let project = null; for (const k of projKeys) { if (projectMap.has(k)) { project = projectMap.get(k); break; } } if (!project) throw new Error(`找不到项目:${row.productCode || row.productName}`); const op = String(row.opType || '').trim(); if (/新增/.test(op)) { const existingId = findUserRoleId(user, role.id, project.id); if (existingId) throw new Error(`权限已存在(user_role_id=${existingId}),跳过`); const ids = await addUserRole(user.id, role.id, project.id); addLog(`✅ 新增成功:${row.email} / ${role.code || role.name} / ${project.code || project.mpc_code || project.name} → user_role_ids=${ids.join(',')}`, 'success'); ok++; } else if (/减|删|取消/.test(op)) { const existingId = findUserRoleId(user, role.id, project.id); if (!existingId) throw new Error('要删除的权限不存在'); await deleteUserRole(existingId); addLog(`🗑️ 删除成功:${row.email} / ${role.code || role.name} / ${project.code || project.mpc_code || project.name}(user_role_id=${existingId})`, 'warn'); ok++; } else { throw new Error(`未知的权限操作类型:${row.opType}`); } } catch (err) { fail++; const reason = err?.message || '未知错误'; addLog(`❌ 第${i + 1}行失败:${reason}`, 'error'); const failed = row.raw.slice(); failed.push(reason); failedRows.push(failed); } await sleep(120); } setProgress(rows.length, rows.length); addLog(`INFO: 完成,成功 ${ok} 行,失败 ${fail} 行`, 'info'); if (failedRows.length) exportFailedXlsx(headersFound, failedRows); } // ====== 失败明细导出 ====== function exportFailedXlsx(headersFound, failedRows) { const wb = XLSX.utils.book_new(); const aoa = [headersFound.concat(['失败原因']), ...failedRows]; const ws = XLSX.utils.aoa_to_sheet(aoa); XLSX.utils.book_append_sheet(wb, ws, '失败明细'); const ts = new Date(); const pad = (n) => String(n).padStart(2, '0'); const name = `权限导入失败_${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.xlsx`; XLSX.writeFile(wb, name); addLog(`⬇️ 已导出失败明细:${name}`, 'info'); } })();