// ==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');
}
})();