首页  编辑  

一个油猴插件,演示了拦截API请求和卡片式渲染数据,模拟react节点操作

Tags: /Node & JS/   Date Created:
Jazz 插件
// ==UserScript==
// @name         Jazz
// @namespace    http://tampermonkey.net/
// @version      2025-03-13
// @description  Jazz project viewer
// @author       Kingron
// @match        http://www.abc.com/contribute/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=www.abc.com
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    const originalXHR = window.XMLHttpRequest;
    let users = new Map();
    let token;
    let urlQuery;
    let data;
    let timeoutId; // 用于存储 setTimeout 的 ID

    // 替换XMLHttpRequest
    window.XMLHttpRequest = function() {
        const xhr = new originalXHR();

        // 保存原生open方法
        const originalOpen = xhr.open;

        // 保存原生send方法
        const originalSend = xhr.send;
        const originalSetRequestHeader = xhr.setRequestHeader;
        xhr.setRequestHeader = function(header, value) {
            if ("Authorization" === header) token = value;
            originalSetRequestHeader.apply(this, arguments);
        };

        // 重写open方法
        xhr.open = function(method, url) {
            this._method = method;
            this._url = url;
            return originalOpen.apply(this, arguments);
        };

        // 重写send方法
        xhr.send = function(d) {
            this._requestData = d;

            // 保存原生onreadystatechange
            const originalOnreadystatechange = this.onreadystatechange;

            // 重写onreadystatechange
            this.onreadystatechange = function() {
                // 调用原始回调函数
                if (originalOnreadystatechange) {
                    return originalOnreadystatechange.apply(this, arguments);
                }

                if (this.readyState === 4) {
                    // 拦截特定URL的请求
                    if (this._url.includes('queryDashboardPageList')) {
                        urlQuery = this._url;
                        console.log('拦截到请求: ', urlQuery);
                        data = JSON.parse(this.responseText);
                        run();
                    }

                    if (this._url.includes('/api/users/all')) {
                        console.log('拦截到请求: users');
                        const result = JSON.parse(this.responseText);
                        result.data.forEach(user => users.set(user.id, user));
                    }
                }
            };

            return originalSend.apply(this, arguments);
        };

        return xhr;
    };

    // 将 UTC 时间转换为本地时间并提取日期部分
    function formatDate(utcDateString) {
        const date = new Date(utcDateString); // 将字符串转换为 Date 对象
        const localDate = date.toLocaleDateString(); // 转换为本地日期字符串
        return localDate;
    }

    const pageDownEvent = new KeyboardEvent('keydown', {
        key: 'PageDown',
        code: 'PageDown',
        which: 34,
        keyCode: 34,
        bubbles: true
    });

    function run() {
        document.getElementById("root").style.opacity = "0";
        document.getElementById("root").style.pointerEvents = "none";
        document.getElementById("root").style.position = "absolute";
        document.getElementById("root").style.zindex = "-1";

        let overlayDiv = document.getElementById('overlayDiv');
        if (!overlayDiv) {
            overlayDiv = document.createElement("div");
            overlayDiv.id = 'overlayDiv';
            overlayDiv.innerHTML = `
<style>
      body {
        font-family: Arial, sans-serif;
        background-color: #f4f4f4;
        margin: 0;
        padding: 0;
        line-height:1.2;
      }

      .card:focus {
        outline: 2px solid black; /* 黑色边框 */
        box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* 可选:添加阴影效果 */
      }

      .container {
        display: flex;
        flex-wrap: wrap; /* 允许换行 */
        gap: 20px;
        justify-content: left;
        padding: 65px 10px 10px 10px;
      }

      .card {
        width: 360px;
        height: 423px;
        background-color: #c6f4c6;
        border-radius: 8px;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        display: flex;
        flex-direction: column;
        box-sizing: border-box;
      }

      .card .header {
        display: flex;
        padding: 10px 10px 0 10px;
        justify-content: space-between;
        align-items: center;
        font-weight: bold;
      }

      .card .header span {
        font-size: 14px;
        color: #777;
      }

      .card .details {
        padding: 5px 10px 0 8px;
      }

      .card .details .name {
        font-size: 16px;
        font-weight: bold;
        width:300px;
        overflow: hidden;
      }

      .card .details .date {
        color: #777;
      }

      .card .stats {
        display: flex;
        justify-content: space-between;
        align-items: center;
      }

      .card .chart {
        width: 100%;
        height: 210px;
      }

      .chart img {
        width: 360px;
        height: 210px;
        border-radius: 3px;
      }

      #dialogBody img {
        width: 99%;
        height: 99%;
        border-radius: 3px;
      }

      .card .warning {
        padding: 4px 10px 0px 10px;
        font-size: 14px;
        color: #333;
        text-overflow: ellipsis;
        -webkit-box-orient: vertical;
        line-clamp: 2;
        height: 38px;
        overflow: hidden;
        margin-top:-209px;
      }

      .card .actionplan {
        background-color: rgba(0, 204, 0, 0.3);
        padding: 4px 10px 0px 10px;
        font-size: 14px;
        color: #333;
        text-overflow: ellipsis;
        -webkit-box-orient: vertical;
        line-clamp: 2;
        height: 38px;
        overflow: hidden;
        border-radius: 0 0 8px 8px;
        margin-top: 138px;
      }

      /* 保证小屏幕时卡片也会适配布局 */
      @media (max-width: 1200px) {
        .container {
          justify-content: center;
        }
      }

      /* 移动端适配,1行显示1个卡片 */
      @media (max-width: 768px) {
        .container {
          justify-content: center;
        }
        .card {
          width: 90%; /* 调整卡片宽度为90% */
        }
      }

      .toolbar {
        background-color: #000;
        color: white;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 10px 20px;
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        z-index: 1000;
      }

      .toolbar .search-box {
        flex-grow: 1;
        margin: 0 20px;
        align-items: center;
        display: flex;
      }

      .toolbar > div:last-child {
        margin-right: 20px;
      }
    </style>
    <div class="toolbar">
      <div>
        <img
          style="
            padding: 0;
            margin: 0;
            width: 50px;
            height: 50px;
            position: fixed;
            top: -2px;
            left: 10px;
            cursor: pointer;
          "
          src="https://img.icons8.com/?size=100&id=7zme8ZmxBZhC&format=png&color=000000"
        />
      </div>
      <div style="margin-left: 40px">Jazz</div>
      <div class="search-box">
        <input
          id="filter"
          type="text"
          placeholder="搜索..."
          style="
            height: 25px;
            border-radius: 8px;
            border: 1px solid #ccc;
            box-shadow: none;
            padding: 0 10px 0 10px;
            width: 10%;
            min-width: 100px;
          "
        />&nbsp;
        <div style="display: flex; align-items: center; gap: 3px;">
          <select id="cbLocation" value="办公室列表" title="Location">
            <option value="" disabled selected hidden>办公室列表</option>
            <option value="">所有办公室</option>
          </select>
          <select id="cbPM" value="PM列表" title="Project Manager">
            <option value="" disabled selected hidden>PM列表</option>
            <option value="">所有PM</option>
          </select>
          <select id="cbSL" value="SL列表" title="System Leader">
            <option value="" disabled selected hidden>SL列表</option>
            <option value="">所有SL</option>
          </select>
          <select id="cbDL" value="DL列表" title="Developer Leader">
            <option value="">所有DL</option>
            <option value="" disabled selected hidden>DL列表</option>
          </select>
          <select id="cbProjectStatus" value="项目状态" title="Project Status">
            <option value="" disabled selected hidden>项目状态</option>
            <option value="Active">Active</option>
            <option value="Finish">Finish</option>
            <option value="">All</option>
          </select>
          <img src="https://img.icons8.com/?size=100&id=a2x1ggyePUME&format=png&color=FFFFFF" alt="上一页" width=24 height=24 id="searchBack" />
          <img src="https://img.icons8.com/?size=100&id=a3XV2uYjYIEG&format=png&color=FFFFFF" alt="上一页" width=24 height=24 id="searchNext" />
          <div id="pagingStatus"></div>
        </div>
      </div>
      <div title="User profile menu">profile&nbsp;</div>
    </div>

    <div class="container" id="cards"></div>
    <div id="dialog" tabindex="0" style="width: 90%; height: 90%; display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: white; padding: 20px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); z-index: 1000;">
      <div style="display: flex; flex-direction: column; height: 100%;">
        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
            <span style="font-size: 18px; font-weight: bold;">详情</span>
            <button id="closeDialog" style="background: none; border: none; font-size: 20px; cursor: pointer;" onclick="document.getElementById('dialog').style.display='none'">&times;</button>
        </div>
        <div id="dialogBody" style="flex: 1; overflow-y: auto; border: 1px solid #ccc; box-sizing: border-box;"></div>
      </div>
    </div>
    `;
            document.body.appendChild(overlayDiv);

            document.getElementById('searchBack').addEventListener('click', function(event) {
                query(-1);
            });

            document.getElementById('searchNext').addEventListener('click', function(event) {
                query(1);
            });

            document.getElementById('filter').addEventListener('input', function(event) {
                clearTimeout(timeoutId); // 清除之前的定时器

                timeoutId = setTimeout(function() {
                    const inputValue = event.target.value;
                    search();
                }, 200);
            });

            document.getElementById('cbProjectStatus').addEventListener('change', (event) => {
                const xhr = new XMLHttpRequest();
                xhr.open("GET", `/api/dashboard/queryDashboardPageList?page=1&pageSize=100&projectStatus=${event.target.value}&location=&dl=&pm=&projectType=`, true);
                xhr.setRequestHeader('Authorization', token);
                xhr.send();
            });
        }

        parseUser();
        init();
        search();
    }

    window.addEventListener('keydown', function(event) {
        if (event.key === 'Escape') {
            document.getElementById('dialog').style.display = 'none';
        }
    });

    function query(page) {
        const xhr = new XMLHttpRequest;
        // /api/dashboard/queryDashboardPageList?page=1&pageSize=100&projectStatus=Active&location=&dl=&pm=&projectType=
        let n = (+data.data.meta.page) + page;
        if (n <= 1) {
            alert("已经是第一页");
            return;
        }
        if (n * data.data.meta.pageSize > data.data.meta.total) {
            alert("已经是最后一页");
            return;
        }
        const newUrl = urlQuery.replace(/([?&]page=)\d+/, `$1${n}`);
        xhr.open("GET", newUrl, true);
        xhr.setRequestHeader('Authorization', token);
        xhr.send();
    }

    async function editProject(name, col) {
        console.log('edit project: ', name, col);
        const step = 5;
        var node;
        var index = 0;
        const start = new Date();
        while(true) {
            node = document.evaluate(`//div[@role="cell" and @data-field="projectTeam"]/div[@class="MuiDataGrid-cellContent"][text()="${name}"]`, document).iterateNext();
            if (node) break;

            document.evaluate(`//*[@id="root"]/div/div/main/div[2]/div/div[5]/div/div[2]/div[2]/div/div/div/div[${index}]/div[1]`, document).iterateNext()?.dispatchEvent(pageDownEvent);
            index = index > data.data.records.length ? step : index + step;
            await new Promise(resolve => setTimeout(resolve, 1));
        }
        const end = new Date();
        console.log(`定位节点耗时: ${end - start}ms => `, node);
        if (col) {
            node = node.parentElement.parentElement.querySelector(`div:nth-of-type(${col})`);
        }
        node?.click()
        console.log("Index: ", index);
        if (index > 0) {
            node?.dispatchEvent(new KeyboardEvent('keydown', {
                key: 'Home',
                code: 'Home',
                ctrlKey: true,
                bubbles: true,
                cancelable: true
            }));
        }
    }

    function initCb(property, cb) {
        var select = document.getElementById(cb);
        if (!select) return;

        if (select.options.length == 2) {
            select.addEventListener('change', () => {
                console.log('Onchange: ', select);
                selectChange(select, property);
            });
        }
        select.options.length = 2;
        const names = [...new Set(data.data.records.map(item =>item[property]))];
        names.sort();
        names.forEach(name => {
            const option = document.createElement('option');
            option.value = name;
            option.textContent = name;
            select.appendChild(option);
        });
    }

    function parseUser() {
        data.data.records.forEach(item => {
            item.dl = item.dl ? (users.get(item.dl)?.name || "") + "(" + users.get(item.dl)?.cname + ")" : "-";
            item.pm = item.pm ? (users.get(item.pm)?.name || "") + "(" + users.get(item.pm)?.cname + ")" : "-";
            item.sl = item.sl ? (users.get(item.sl)?.name || "") + "(" + users.get(item.sl)?.cname + ")" : "-";
        });
        data.data.records.sort((a,b) => a.dl?.localeCompare(b.dl, undefined, { sensitivity: 'base' }));
    }

    function init() {
        initCb('pm', 'cbPM');
        initCb('location', 'cbLocation');
        initCb('sl', 'cbSL');
        initCb('dl', 'cbDL');
    }

    function selectChange(select, key) {
        var list = [];
        const value = select.value;
        for (let item of data.data.records) {
            if (item[key] === value || value === "") {
                list.push(item);
            }
        }
        render(list);
    }
    function search() {
        var filter = document.getElementById("filter").value;
        if (filter?.trim() === "") {
            render(data.data.records);
            return;
        }
        var list = [];
        for (let item of data.data.records) {
            if (JSON.stringify(item, (key, value) => {if (key==='burnDownChart' || value == null || value == "") return undefined; return value}, 2).indexOf(filter) > 0) {
                list.push(item);
            }
        }
        render(list);
    }

    function daydiff(t) {
        return (((new Date()).getTime()) - (new Date(t)).getTime()) / 86400000;
    }
    function getRange(s1, s2) {
        const [p1 = "?", p2 = "?"] = [s1?.substring(5, 10), s2?.substring(5, 10)];
        return s1 === null && s2 === null ? "" : `(${p1} - ${p2})`;
    }

    function escapeHtml(unsafe) {
        return unsafe
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
    }

    const modelImg = {
        "null": "https://img.icons8.com/?size=100&id=7744&format=png&color=000000",
        "ModelA": "https://img.icons8.com/?size=100&id=WJhQO4L0uz5D&format=png&color=0000FF",
        "ModelB": "https://img.icons8.com/?size=100&id=9542&format=png&color=000000",
        "ModelC": "https://img.icons8.com/?size=100&id=yy5lqoytli5E&format=png&color=800080",
        "ModelC(Without PM)": "https://img.icons8.com/?size=100&id=2NjzoyC1SSOE&format=png&color=800080"
    }
    function render(list) {
        const pagingDiv = document.getElementById("pagingStatus");
        pagingDiv.innerText = `第 ${data.data.meta.page} 页,总共 ${data.data.meta.total} 条`;
        const cards = document.getElementById("cards");
        cards.innerHTML = "";
        for (let item of list) {
            const risk = (item.risk || "暂无风险").toLowerCase();
            const norisk = risk === "暂无风险" || risk === "暂无" || risk === "" || risk === "n/a" || risk === "no risk" || risk === "无";
            const noactionplan = item.actionPlan === "暂无风险" || item.actionPlan === "暂无" || item.actionPlan == undefined || item.actionPlan === "" || item.actionPlan == null;
            const updateTime = new Date(item.updateTime)
            const days = daydiff(updateTime)
            var card = document.createElement("div");
            var springRange = getRange(item.sprintStartTime, item.sprintEndTime);
            //const raw = escapeHtml(JSON.stringify(item, (key, value) => {if (key==='burnDownChart' || value == null || value == "") return undefined; return value}, 2));

            card.innerHTML = `
                <div class="card" tabindex="0" style="background-color:${ norisk ? "#c6f4c6" : "#ffcc00" }" onclick="this.focus()">
                    <div class="header">
                        <div title="办公室" style="display:flex;algin-items:center"><img src="https://img.icons8.com/?size=100&id=kIuNfCeXdCvG&format=png&color=000000" width=18 height=18>&nbsp;${item.location}</div><div title="项目编号 - 当前迭代周期" class="projectInfo" team="${item.projectTeam}" style="cursor:pointer">${item.srNumber} - <font color="blue">#${item.sprint}${springRange}</font></div>
                    </div>
                    <div class="projectTeam" team="${item.projectTeam}" title="团队名称(Project Team)" style="cursor:pointer;border-bottom: 1px solid #fff;padding: 0 10px 5px 10px; max-width:360px;white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"><font color="blue">${item.projectPhase || "N/A"}</font> - ${item.projectTeam}</div>
                    <div class="details" style="border-bottom: 1px solid #fff">
                        <table width='100%'><tbody>
                          <tr>
                              <td><b>PM</b>: ${item.pm}</td>
                              <td align="right">SR 时间范围</td>
                          </tr>
                          <tr>
                              <td><b>SL</b>: ${item.sl}</td>
                              <td align="right">${formatDate(item.srStartTime)}</td>
                          </tr>
                          <tr>
                              <td><b>DL</b>: ${item.dl}</td>
                              <td align="right"><span style="color:${daydiff(item.srEndTime) > -30 ? "red" : "default"}">${formatDate(item.srEndTime)}</span></td>
            </tr>
            </tbody></table>
                </div>
            <div class="stats" style="padding: 5px 10px 5px 8px">
                <table><tbody>
                <tr>
                <td rowspan="3" align="center" class="projectBugs" team="${item.projectTeam}" style="cursor:pointer"><img src="https://img.icons8.com/?size=100&id=9227&format=png&color=000000" width=32 height=32 title="Bug 历史记录"><br>
                    <div title="DefectDensity,标准值: 0.264" style="color: ${item.totalDefectDensity >= 0.264 ? "Red": "Green"}">${item.totalDefectDensity}</div>
            </td>
            <td>Sprint #${item.bugRecords[0]?.sprint || '-'}: ${item.bugRecords[0]?.bugNumber || '-'}</td>
            </tr>
            <tr>
                <td>Sprint #${item.bugRecords[1]?.sprint || '-'}: ${item.bugRecords[1]?.bugNumber || '-'}</td>
            </tr>
            <tr>
                    <td>Sprint #${item.bugRecords[2]?.sprint || '-'}: ${item.bugRecords[2]?.bugNumber || '-'}</td>
            </tr>
            </tbody></table>
                <div class="team-size">
                    <table style="border-collapse: collapse;"><tbody>
                        <tr>
                        <td align="right" style="align: right"><img src="${modelImg[item.projectType]}" width=32 height=32 title="项目模式: ${item.projectType}"></td>
        <td><span style="font-size:32px">&nbsp;${item.teamMember}</span></td>
            </tr>
        <tr>
            <td colspan=2><div style="color: ${days > 7 ? "red" : "#000" }">更新于: ${updateTime.toLocaleDateString()}</div></td>
                </tr>
        </tbody></table>
            </div>
        </div>
        <div class="chart" style="border-top:1px solid #fff;cursor:pointer">${item.burnDownChart?.replace("<p>", "").replace("</p>", "") || ""}</div>
        <div class="warning projectRisk" title="${item.risk}" team="${item.projectTeam}"  style="cursor:pointer; background-color: ${norisk ? 'rgba(0, 204, 0, 0.3)' : 'rgba(255, 204, 0, 0.3)'}">${item.risk}</div>
        <div class="actionplan" title="${item.actionPlan}" style="display: ${noactionplan ? 'none' : 'block'}">${item.actionPlan}</div>
        </div>
        `;
            cards.appendChild(card);
        }
        document.querySelectorAll('.projectTeam').forEach(item => {
            const name = item.getAttribute('team');
            item.addEventListener('click', function() {
                editProject(name);
            });
        });

        document.querySelectorAll('.projectBugs').forEach(item => {
            const name = item.getAttribute('team');
            item.addEventListener('click', function() {
                editProject(name, 14);
            });
        });

        document.querySelectorAll('.projectInfo').forEach(item => {
            const name = item.getAttribute('team');
            item.addEventListener('click', function() {
                editProject(name, 9);
            });
        });

        document.querySelectorAll('.projectRisk').forEach(item => {
            const name = item.getAttribute('team');
            item.addEventListener('click', function() {
                editProject(name, 13);
            });
        });

        const charts = document.querySelectorAll(".chart")
        charts.forEach(chart => {
            chart.addEventListener('click', function() {
                const dialogBody = document.getElementById('dialogBody');
                dialogBody.innerHTML = chart.innerHTML;
                document.getElementById('dialog').style.display="block";
            });
        });
    }
})();
queryDashbaord返回的数据结构:
{
  "code": 200,
  "message": "fetched successfully",
  "data": {
    "records": [
      {
        "id": 2,
        "location": "US",
        "projectTeam": "Team1",
        "sl": "Bill Gates",
        "dl": "Joe Biden",
        "pm": "Donald Trump",
        "sprint": 53,
        "burnDownChart": "<p><img src=\"data:image/png;base64,iVBORw0AElFTkSuQmCC\"></p>",
        "risk": "N/A",
        "actionPlan": "N/A",
        "riskLevel": "",
        "owner": "",
        "riskStatus": "",
        "teamMember": 10,
        "internalDemo": null,
        "demoReviewForHA": null,
        "technologyStack": null,
        "srStartTime": "2024-12-31T16:00:00.000Z",
        "srEndTime": "2025-05-30T16:00:00.000Z",
        "renewalInfo": null,
        "projectPhase": "Coding",
        "remark": null,
        "createdUserId": null,
        "createdBy": null,
        "createTime": null,
        "updatedBy": "Bill Gates",
        "updateTime": "2025-05-07T05:22:27.000Z",
        "projectStatus": "Active",
        "srNumber": "SR-1234",
        "sprintStartTime": "2025-04-20T16:00:00.000Z",
        "sprintEndTime": "2025-05-08T16:00:00.000Z",
        "projectType": "ModelB",
        "slName": "Bill Gates",
        "pmName": "Joe Bidon",
        "dlName": "Charles Hank",
        "risks": [
          {
            "id": 335,
            "location": "US",
            "sprint": 55,
            "projectTeam": "Team1",
            "dl": 199,
            "risk": "N/A",
            "actionPlan": "Blabla",
            "owner": "",
            "riskStatus": "Closed",
            "riskLevel": "",
            "recordDate": "2025-04-24T07:57:49.000Z",
            "completeDate": "2025-05-08T07:21:52.000Z",
            "dashboardId": 8,
            "createdUserId": 199,
            "createdBy": null,
            "createTime": "2025-05-08T07:21:52.000Z",
            "updatedBy": null,
            "updateTime": "2025-05-08T07:52:00.000Z"
          }
        ],
        "bugRecords": [
          {
            "dashboardProjectId": 2,
            "sprint": 52,
            "bugNumber": 3,
            "actualEfforts": 119,
            "defectDensity": 0.025
          },
          {
            "dashboardProjectId": 2,
            "sprint": 51,
            "bugNumber": 3,
            "actualEfforts": 99,
            "defectDensity": 0.03
          },
          {
            "dashboardProjectId": 2,
            "sprint": 50,
            "bugNumber": 3,
            "actualEfforts": 117,
            "defectDensity": 0.026
          }
        ],
        "totalDefectDensity": 0.028
      }
    ],
    "meta": {
      "page": "1",
      "pageSize": "100",
      "total": 1
    }
  }
}
user 数据结构:
{
  "code": 200,
  "message": "UserList fetched successfully",
  "data": [
    {
      "id": 1,
      "name": "admin",
      "cname": "管理员",
      "email": "admin"
    }
  ]
}
效果: