NGA Account Switcher

快速切换多个账号

// ==UserScript==
// @name        NGA Account Switcher
// @name:zh-CN  NGA 账号切换
// @namespace   https://greasyfork.org/users/263018
// @version     1.0.0
// @author      snyssss
// @description 快速切换多个账号
// @license     MIT

// @match       *://bbs.nga.cn/*
// @match       *://ngabbs.com/*
// @match       *://nga.178.com/*

// @require     https://update.greasyfork.org/scripts/486070/1377381/NGA%20Library.js

// @grant       GM_addStyle
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @grant       unsafeWindow

// @run-at      document-start
// ==/UserScript==

(() => {
  // 声明泥潭主模块
  let commonui;

  // 声明 UI
  let ui;

  // STYLE
  GM_addStyle(`
    .s-table-wrapper {
        max-height: 80vh;
        overflow-y: auto;
    }
    .s-table {
        margin: 0;
    }
    .s-table th,
    .s-table td {
        position: relative;
        white-space: nowrap;
    }
    .s-table th {
        position: sticky;
        top: 2px;
        z-index: 1;
    }
    .s-table input:not([type]), .s-table input[type="text"] {
        margin: 0;
        box-sizing: border-box;
        height: 100%;
        width: 100%;
    }
    .s-input-wrapper {
        position: absolute;
        top: 6px;
        right: 6px;
        bottom: 6px;
        left: 6px;
    }
    .s-text-ellipsis {
        display: flex;
    }
    .s-text-ellipsis > * {
        flex: 1;
        width: 1px;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .s-button-group {
        margin: -.1em -.2em;
    }
  `);

  /**
   * UI
   */
  class UI {
    /**
     * 标签
     */
    static label = "账号切换";

    /**
     * 弹出窗
     */
    window;

    /**
     * 视图元素
     */
    views = {};

    /**
     * 初始化
     */
    constructor() {
      this.init();
    }

    /**
     * 初始化,创建基础视图,初始化通用设置
     */
    init() {
      const tabs = this.createTabs({
        className: "right_",
      });

      const content = this.createElement("DIV", [], {
        style: "width: 400px;",
      });

      const container = this.createElement("DIV", [tabs, content]);

      this.views = {
        tabs,
        content,
        container,
      };
    }

    /**
     * 创建元素
     * @param   {String}                               tagName    标签
     * @param   {HTMLElement | HTMLElement[] | String} content    内容,元素或者 innerHTML
     * @param   {*}                                    properties 额外属性
     * @returns {HTMLElement}                                     元素
     */
    createElement(tagName, content, properties = {}) {
      const element = document.createElement(tagName);

      // 写入内容
      if (typeof content === "string") {
        element.innerHTML = content;
      } else {
        if (Array.isArray(content) === false) {
          content = [content];
        }

        content.forEach((item) => {
          if (item === null) {
            return;
          }

          if (typeof item === "string") {
            element.append(item);
            return;
          }

          element.appendChild(item);
        });
      }

      // 对 A 标签的额外处理
      if (tagName.toUpperCase() === "A") {
        if (Object.hasOwn(properties, "href") === false) {
          properties.href = "javascript: void(0);";
        }
      }

      // 附加属性
      Object.entries(properties).forEach(([key, value]) => {
        element[key] = value;
      });

      return element;
    }

    /**
     * 创建按钮
     * @param {String}   text       文字
     * @param {Function} onclick    点击事件
     * @param {*}        properties 额外属性
     */
    createButton(text, onclick, properties = {}) {
      return this.createElement("BUTTON", text, {
        ...properties,
        onclick,
      });
    }

    /**
     * 创建按钮组
     * @param {Array} buttons 按钮集合
     */
    createButtonGroup(...buttons) {
      return this.createElement("DIV", buttons, {
        className: "s-button-group",
      });
    }

    /**
     * 创建表格
     * @param   {Array}       headers    表头集合
     * @param   {*}           properties 额外属性
     * @returns {HTMLElement}            元素和相关函数
     */
    createTable(headers, properties = {}) {
      const rows = [];

      const ths = headers.map((item, index) =>
        this.createElement("TH", item.label, {
          ...item,
          className: `c${index + 1}`,
        })
      );

      const tr =
        ths.length > 0
          ? this.createElement("TR", ths, {
              className: "block_txt_c0",
            })
          : null;

      const thead = tr !== null ? this.createElement("THEAD", tr) : null;

      const tbody = this.createElement("TBODY", []);

      const table = this.createElement("TABLE", [thead, tbody], {
        ...properties,
        className: "s-table forumbox",
      });

      const wrapper = this.createElement("DIV", table, {
        className: "s-table-wrapper",
      });

      const intersectionObserver = new IntersectionObserver((entries) => {
        if (entries[0].intersectionRatio <= 0) return;

        const list = rows.splice(0, 10);

        if (list.length === 0) {
          return;
        }

        intersectionObserver.disconnect();

        tbody.append(...list);

        intersectionObserver.observe(tbody.lastElementChild);
      });

      const add = (...columns) => {
        const tds = columns.map((column, index) => {
          if (ths[index]) {
            const { center, ellipsis } = ths[index];

            const properties = {};

            if (center) {
              properties.style = "text-align: center;";
            }

            if (ellipsis) {
              properties.className = "s-text-ellipsis";
            }

            column = this.createElement("DIV", column, properties);
          }

          return this.createElement("TD", column, {
            className: `c${index + 1}`,
          });
        });

        const tr = this.createElement("TR", tds, {
          className: `row${(rows.length % 2) + 1}`,
        });

        intersectionObserver.disconnect();

        rows.push(tr);

        intersectionObserver.observe(tbody.lastElementChild || tbody);
      };

      const update = (e, ...columns) => {
        const row = e.target.closest("TR");

        if (row) {
          const tds = row.querySelectorAll("TD");

          columns.map((column, index) => {
            if (ths[index]) {
              const { center, ellipsis } = ths[index];

              const properties = {};

              if (center) {
                properties.style = "text-align: center;";
              }

              if (ellipsis) {
                properties.className = "s-text-ellipsis";
              }

              column = this.createElement("DIV", column, properties);
            }

            if (tds[index]) {
              tds[index].innerHTML = "";
              tds[index].append(column);
            }
          });
        }
      };

      const remove = (e) => {
        const row = e.target.closest("TR");

        if (row) {
          tbody.removeChild(row);
        }
      };

      const clear = () => {
        rows.splice(0);
        intersectionObserver.disconnect();

        tbody.innerHTML = "";
      };

      Object.assign(wrapper, {
        add,
        update,
        remove,
        clear,
      });

      return wrapper;
    }

    /**
     * 创建标签组
     * @param {*} properties 额外属性
     */
    createTabs(properties = {}) {
      const tabs = this.createElement(
        "DIV",
        `<table class="stdbtn" cellspacing="0">
          <tbody>
            <tr></tr>
          </tbody>
        </table>`,
        properties
      );

      return this.createElement(
        "DIV",
        [
          tabs,
          this.createElement("DIV", [], {
            className: "clear",
          }),
        ],
        {
          style: "display: none; margin-bottom: 5px;",
        }
      );
    }

    /**
     * 创建标签
     * @param {Element} tabs       标签组
     * @param {String}  label      标签名称
     * @param {Number}  order      标签顺序,重复则跳过
     * @param {*}       properties 额外属性
     */
    createTab(tabs, label, order, properties = {}) {
      const group = tabs.querySelector("TR");

      const items = [...group.childNodes];

      if (items.find((item) => item.order === order)) {
        return;
      }

      if (items.length > 0) {
        tabs.style.removeProperty("display");
      }

      const tab = this.createElement("A", label, {
        ...properties,
        className: "nobr silver",
        onclick: () => {
          if (tab.className === "nobr") {
            return;
          }

          group.querySelectorAll("A").forEach((item) => {
            if (item === tab) {
              item.className = "nobr";
            } else {
              item.className = "nobr silver";
            }
          });

          if (properties.onclick) {
            properties.onclick();
          }
        },
      });

      const wrapper = this.createElement("TD", tab, {
        order,
      });

      const anchor = items.find((item) => item.order > order);

      group.insertBefore(wrapper, anchor || null);

      return wrapper;
    }

    /**
     * 创建对话框
     * @param {HTMLElement | null} anchor  要绑定的元素,如果为空,直接弹出
     * @param {String}             title   对话框的标题
     * @param {HTMLElement}        content 对话框的内容
     */
    createDialog(anchor, title, content) {
      let window;

      const show = () => {
        if (window === undefined) {
          window = commonui.createCommmonWindow();
        }

        window._.addContent(null);
        window._.addTitle(title);
        window._.addContent(content);
        window._.show();
      };

      if (anchor) {
        anchor.onclick = show;
      } else {
        show();
      }

      return window;
    }

    /**
     * 弹窗确认
     * @param   {String}  message 提示信息
     * @returns {Promise}
     */
    confirm(message = "是否确认?") {
      return new Promise((resolve, reject) => {
        const result = confirm(message);

        if (result) {
          resolve();
          return;
        }

        reject();
      });
    }

    /**
     * 渲染视图
     */
    renderView() {
      // 创建或打开弹出窗
      if (this.window === undefined) {
        this.window = this.createDialog(
          this.views.anchor,
          this.constructor.label,
          this.views.container
        );
      } else {
        this.window._.show();
      }

      // 启用第一个模块
      this.views.tabs.querySelector("A").click();
    }

    /**
     * 渲染
     */
    render() {
      this.renderView();
    }
  }

  /**
   * 基础模块
   */
  class Module {
    /**
     * 模块名称
     */
    static name;

    /**
     * 模块标签
     */
    static label;

    /**
     * 顺序
     */
    static order;

    /**
     * UI
     */
    ui;

    /**
     * 视图元素
     */
    views = {};

    /**
     * 初始化并绑定UI,注册 UI
     * @param {UI} ui UI
     */
    constructor(ui) {
      this.ui = ui;

      this.init();
    }

    /**
     * 获取列表
     */
    get list() {
      return GM_getValue(this.constructor.name, {});
    }

    /**
     * 写入列表
     */
    set list(value) {
      GM_setValue(this.constructor.name, value);
    }

    /**
     * 初始化,创建基础视图和组件
     */
    init() {
      if (this.views.container) {
        this.destroy();
      }

      const { ui } = this;

      const container = ui.createElement("DIV", []);

      this.views = {
        container,
      };

      this.initComponents();
    }

    /**
     * 初始化组件
     */
    initComponents() {}

    /**
     * 销毁
     */
    destroy() {
      Object.values(this.views).forEach((view) => {
        if (view.parentNode) {
          view.parentNode.removeChild(view);
        }
      });

      this.views = {};
    }

    /**
     * 渲染
     * @param {HTMLElement} container 容器
     */
    render(container) {
      container.innerHTML = "";
      container.appendChild(this.views.container);
    }
  }

  /**
   * 账号列表
   */
  class AccountList extends Module {
    /**
     * 模块名称
     */
    static name = "data";

    /**
     * 模块标签
     */
    static label = "账号";

    /**
     * 顺序
     */
    static order = 10;

    /**
     * 表格列
     * @returns {Array} 表格列集合
     */
    columns() {
      return [
        { label: "昵称" },
        { label: "登录时间" },
        { label: "操作", width: 1 },
      ];
    }

    /**
     * 表格项
     * @param   {Object} item 账号信息
     * @returns {Array}       表格项集合
     */
    column(item) {
      const { ui } = this;

      const { table } = this.views;

      const { uid, username, timestamp } = item;

      // 昵称
      const name = (() => {
        const label = username ? "@" + username : "#" + uid;

        return ui.createElement("A", `[${label}]`, {
          className: "b nobr",
          href: `/nuke.php?func=ucp&uid=${uid}`,
        });
      })();

      // 登录时间
      const time = ui.createElement(
        "SPAN",
        commonui.time2dis(timestamp / 1000),
        {
          className: "nobr",
        }
      );

      // 操作
      const buttons = (() => {
        const toggle = ui.createButton("切换", (e) => {
          loadData(uid).catch((err) => {
            alert(err.message);

            removeData(uid);

            table.remove(e);
          });
        });

        const remove = ui.createButton("删除", (e) => {
          ui.confirm().then(() => {
            removeData(uid);

            table.remove(e);
          });
        });

        if (unsafeWindow.__CURRENT_UID === uid) {
          return ui.createButtonGroup(remove);
        }

        return ui.createButtonGroup(toggle, remove);
      })();

      return [name, time, buttons];
    }

    /**
     * 初始化组件
     */
    initComponents() {
      super.initComponents();

      const { tabs, content } = this.ui.views;

      const table = this.ui.createTable(this.columns());

      const tab = this.ui.createTab(
        tabs,
        this.constructor.label,
        this.constructor.order,
        {
          onclick: () => {
            this.render(content);
          },
        }
      );

      Object.assign(this.views, {
        tab,
        table,
      });

      this.views.container.appendChild(table);
    }

    /**
     * 渲染
     * @param {HTMLElement} container 容器
     */
    render(container) {
      super.render(container);

      const { list } = this;

      const { table } = this.views;

      if (table) {
        const { add, clear } = table;

        clear();

        Object.values(list).forEach((item) => {
          const column = this.column(item);

          add(...column);
        });
      }
    }
  }

  /**
   * 渲染 UI
   */
  const renderUI = () => {
    if (commonui && commonui.mainMenuItems) {
      if (ui === undefined) {
        ui = new UI();

        new AccountList(ui);
      }

      ui.render();
    }
  };

  /**
   * 处理 commonui 模块
   * @param {*} value commonui
   */
  const handleCommonui = (value) => {
    // 绑定主模块
    commonui = value;

    // 拦截 mainMenu 模块,处理 init 事件
    Tools.interceptProperty(commonui, "mainMenu", {
      afterSet: (mainMenu) => {
        // 加入菜单
        if (mainMenu && mainMenu.addItemOnTheFly) {
          mainMenu.addItemOnTheFly(`账号切换`, null, renderUI);
        }
      },
    });
  };

  /**
   * 注册脚本菜单
   */
  const registerMenu = () => {
    GM_registerMenuCommand(`账号切换`, renderUI);
  };

  /**
   * 拦截登录页面
   */
  const handleLogin = () => {
    if (unsafeWindow.document.title === "账号操作") {
      // 处理 __API 模块
      const handleLoginAPI = (value) => {
        if (value) {
          // 拦截 get 方法,从中取得登录成功后的信息
          Tools.interceptProperty(value, "get", {
            beforeGet: (...args) => {
              if (args[0] === "loginSuccess") {
                const { uid, username, token } = JSON.parse(args[1]);

                saveData(uid, username, token);
              }

              return args;
            },
          });
        }
      };

      if (unsafeWindow.__API) {
        handleLoginAPI(unsafeWindow.__API);
        return;
      }

      Tools.interceptProperty(unsafeWindow, "__API", {
        afterSet: (value) => {
          handleLoginAPI(value);
        },
      });
    }
  };

  /**
   * 载入数据
   * @param {String} uid 用户 ID
   */
  const loadData = async (uid) => {
    const list = GM_getValue(AccountList.name, {});
    const item = list[uid];

    if (item) {
      const { cid } = item;

      const url = `/nuke.php?__lib=login&__act=login_set_cookie_quick`;

      const form = new FormData();

      form.append("uid", uid);
      form.append("cid", cid);

      const response = await fetch(url, {
        method: "POST",
        body: form,
      });

      const result = await Tools.readForumData(response, false);

      const parser = new DOMParser();

      const doc = parser.parseFromString(result, "text/html");

      const message = doc.body.innerText.replace(/\s/g, "");

      if (message === "SUCCESS") {
        unsafeWindow.location.reload();
        return;
      }
    }

    throw new Error("登录状态失效,请重新登录");
  };

  /**
   * 保存数据
   */
  const saveData = (uid, username, cid) => {
    const list = GM_getValue(AccountList.name, {});

    list[uid] = {
      uid,
      username,
      cid,
      timestamp: new Date().getTime(),
    };

    GM_setValue(AccountList.name, list);
  };

  /**
   * 删除数据
   * @param {String} uid 用户 ID
   */
  const removeData = (uid) => {
    const list = GM_getValue(AccountList.name, {});

    delete list[uid];

    GM_setValue(AccountList.name, list);
  };

  // 主函数
  (async () => {
    // 注册脚本菜单
    registerMenu();

    // 拦截登录页面
    handleLogin();

    // 处理 commonui 模块
    if (unsafeWindow.commonui) {
      handleCommonui(unsafeWindow.commonui);
      return;
    }

    Tools.interceptProperty(unsafeWindow, "commonui", {
      afterSet: (value) => {
        handleCommonui(value);
      },
    });
  })();
})();