通用组件库

通用 UI 组件和工具函数库

Tính đến 18-06-2025. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greatest.deepsurf.us/scripts/539247/1609892/%E9%80%9A%E7%94%A8%E7%BB%84%E4%BB%B6%E5%BA%93.js

// ==UserScript==
// @name         通用组件库
// @namespace    https://greatest.deepsurf.us/zh-CN/users/1296281
// @version      1.1.0
// @license      GPL-3.0
// @description  通用 UI 组件和工具函数库
// @author       ShineByPupil
// @match        *
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  const colors = {
    primary: "#4C6EF5",
    success: "#67c23a",
    info: "#909399",
    warning: "#e6a23c",
    danger: "#f56c6c",
  };
  const defaultColors = [];
  const lightColors = [];
  const darkColors = [];

  const mixColor = (color1, color2, percent) => {
    // 去掉井号并转换为 0~255 的整数
    const c1 = color1.replace(/^#/, "");
    const c2 = color2.replace(/^#/, "");
    const r1 = parseInt(c1.substr(0, 2), 16);
    const g1 = parseInt(c1.substr(2, 2), 16);
    const b1 = parseInt(c1.substr(4, 2), 16);
    const r2 = parseInt(c2.substr(0, 2), 16);
    const g2 = parseInt(c2.substr(2, 2), 16);
    const b2 = parseInt(c2.substr(4, 2), 16);

    // 百分比转 0~1
    const t = Math.min(Math.max(percent, 0), 100) / 100;

    // 插值计算
    const r = Math.round(r1 + (r2 - r1) * t);
    const g = Math.round(g1 + (g2 - g1) * t);
    const b = Math.round(b1 + (b2 - b1) * t);

    // 转回两位十六进制,不足两位补零
    const toHex = (x) => x.toString(16).padStart(2, "0");

    return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
  };

  for (let key in colors) {
    const color = colors[key];
    defaultColors.push(`--${key}-color: ${color};`);

    for (let i = 1; i <= 9; i++) {
      const p = i * 10;

      lightColors.push(
        `--${key}-color-light-${i}: ${mixColor(color, "#ffffff", p)};`,
      );
      darkColors.push(
        `--${key}-color-light-${i}: ${mixColor(color, "#141414", p)};`,
      );
    }
  }

  const commonCssTemplate = document.createElement("template");
  commonCssTemplate.innerHTML = `
    <style>
      :host {
        font-family: Inter, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", 微软雅黑, Arial, sans-serif;
      }
      :host {
        ${defaultColors.join("\n")}
        ${lightColors.join("\n")}
        --primary-color-hover: var(--primary-color-linght);
        --border-color: #dcdfe6;
        --border-color-hover: #C0C4CC;
        --border-color-focus: var(--primary-color);
        --bg-color: #FFFFFF;
        --text-color: #333333;
        --overlay-bg: rgba(0, 0, 0, 0.5);
        --box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, .04), 0px 8px 20px rgba(0, 0, 0, .08);
        --placeholder-color: #a8abb2;
      }
      :host-context(.ex),
      :host-context(.dark),
      :host-context([data-theme="dark"]) {
        ${darkColors.join("\n")}
        --primary-color-hover: var(--primary-color-dark);
        --border-color: #4C4D4F;
        --border-color-hover: #6C6E72;
        --border-color-focus: var(--primary-color);
        --bg-color: #141414;
        --text-color: #CFD3DC;
        --placeholder-color: #8D9095;
      }
      
      button {
        color: inherit;
        cursor: pointer;
      }
    </style>
  `;

  class Input extends HTMLElement {
    input = null;

    constructor() {
      super();

      const htmlTemplate = document.createElement("template");
      htmlTemplate.innerHTML = `<input type="text" />`;

      const cssTemplate = document.createElement("template");
      cssTemplate.innerHTML = `
        <style>
          :host {
            display: inline-flex;
            height: 32px;
            vertical-align: top;
          }
          
          input {
            width: 100%;
            height: 100%;
            color: var(--text-color);
            outline: none;
            box-sizing: border-box;
            padding: 4px 11px;
            border-radius: 4px;
            border: 1px solid var(--border-color);
            transition: all 0.3s;
            background-color: var(--bg-color);
          }
          
          input:hover {
            border-color: var(--border-color-hover);
          }
          input:focus {
            border-color: var(--border-color-focus);
            border-inline-end-width: 1px;
          }
          input::placeholder {
            color: var(--placeholder-color);
          }
        </style>
      `;

      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(
        htmlTemplate.content,
        commonCssTemplate.content.cloneNode(true),
        cssTemplate.content,
      );
      this.input = this.shadowRoot.querySelector("input");
    }

    connectedCallback() {
      this.input.addEventListener("input", (e) => {
        e.stopPropagation();
        this.value = e.target.value;
        this.dispatchEvent(new CustomEvent("input", { detail: this.value }));
      });

      Object.values(this.attributes).forEach((attr) => {
        if (!/^on/.test(attr.name)) {
          this.input.setAttribute(attr.name, attr.value);
        }
      });

      const mo = new MutationObserver((mutationsList) => {
        for (const m of mutationsList) {
          if (m.type === "attributes") {
            const val = this.getAttribute(m.attributeName);
            if (val === null) {
              this.input.removeAttribute(m.attributeName);
            } else {
              this.input.setAttribute(m.attributeName, val);
            }
          }
        }
      });
      mo.observe(this, { attributes: true });
    }

    get value() {
      return this.input.value;
    }
    set value(val) {
      this.input.value = val;
    }
  }

  // todo
  class Option extends HTMLElement {
    constructor() {
      super();
    }
  }

  // todo
  class Select extends HTMLElement {
    constructor() {
      super();
    }
  }

  class Button extends HTMLElement {
    constructor() {
      super();

      const htmlTemplate = document.createElement("template");
      htmlTemplate.innerHTML = `
        <button>
          <slot></slot>
        </button>
      `;

      const cssTemplate = document.createElement("template");
      cssTemplate.innerHTML = `
        <style>
          :host {
            --bg-color: var(--bg-color);
            --bg-color-hover: var(--primary-color-light-9);
            --button-border-color: var(--border-color);
            --button-border-color-hover: var(--primary-color);
            --text-color-hover: var(--primary-color);
          }
          ${Object.keys(colors)
            .map((type) => {
              return `
              :host([type='${type}']) {
                --text-color: #FFFFFF;
                --text-color-hover: #FFFFFF;
                --bg-color: var(--${type}-color);
                --bg-color-hover: var(--${type}-color-light-3);
                --button-border-color: var(--${type}-color);
                --button-border-color-hover: var(--${type}-color-light-3);
              }
            `;
            })
            .join("\n")}
          :host {
            display: inline-flex;
            width: fit-content;
            height: 32px;
          }
        
          button {
            display: inline-flex;
            align-items: center;
            font-family: inherit;
            color: var(--text-color);
            padding: 8px 15px;
            background-color: var(--bg-color);
            border-radius: 5px;
            border: 1px solid var(--button-border-color);
            transition: all 0.3s;
            outline: none;
          }
          button:hover {
            color: var(--text-color-hover);
            background-color: var(--bg-color-hover);
            border-color: var(--button-border-color-hover);
          }
        </style>
      `;

      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(
        htmlTemplate.content,
        commonCssTemplate.content.cloneNode(true),
        cssTemplate.content,
      );
    }
  }

  class Switch extends HTMLElement {
    // 事件来源类型: user | broadcast
    #currentChangeSource = "user";

    static get observedAttributes() {
      return ["checked", "disabled", "@change"];
    }

    constructor() {
      super();

      const htmlTemplate = document.createElement("template");
      htmlTemplate.innerHTML = `
        <div class="track">
          <div class="thumb"></div>
        </div>`;

      const cssTemplate = document.createElement("template");
      cssTemplate.innerHTML = `
        <style>
          :host {
            --bg-color: #ccc;
            --cursor: pointer;
          }
          :host {
            display: inline-block;
            aspect-ratio: 2/1;
            height: 20px;
          }
          :host([checked]) {
            --bg-color: ${colors.primary};
          }
          :host([checked]) .thumb {
            transform: translateX(calc(100% + 4px));
          }
          :host([disabled]) {
            --cursor: not-allowed;
          }
          .track {
            width: 100%;
            height: 100%;
            background: var(--bg-color);
            border-radius: 14px;
            position: relative;
            transition: background .3s;
            cursor: var(--cursor);
            outline: none;
          }
          .thumb {
            aspect-ratio: 1/1;
            height: calc(100% - 4px);
            background: #fff;
            border-radius: 50%;
            position: absolute;
            top: 2px;
            left: 2px;
            transition: transform .3s;
          }
        </style>
      `;

      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(htmlTemplate.content, cssTemplate.content);
    }

    connectedCallback() {
      const track = this.shadowRoot.querySelector(".track");

      track.addEventListener("click", () => this.toggle());
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === "checked" && oldValue !== newValue) {
        const oldChecked = oldValue !== null;
        const newChecked = newValue !== null;

        this.dispatchEvent(
          new CustomEvent("change", {
            detail: {
              value: newChecked,
              oldValue: oldChecked,
              source: this.#currentChangeSource,
            },
          }),
        );

        this.#currentChangeSource = "user";
      }
    }

    get checked() {
      return this.hasAttribute("checked");
    }
    set checked(val) {
      val ? this.setAttribute("checked", "") : this.removeAttribute("checked");
    }
    get disabled() {
      return this.hasAttribute("disabled");
    }
    set disabled(val) {
      val
        ? this.setAttribute("disabled", "")
        : this.removeAttribute("disabled");
    }

    toggle() {
      if (!this.disabled) this.checked = !this.checked;
    }

    // 静默更新方法(不触发事件)
    updateFromBroadcast(value) {
      this.#currentChangeSource = "broadcast";
      this.checked = value;
    }
  }

  class MessageBox extends HTMLElement {
    static #instance = null;
    static observedAttributes = ["type"];

    constructor() {
      super();

      this.type = this.getAttribute("type");

      const htmlTemplate = document.createElement("template");
      htmlTemplate.innerHTML = `
        <div class="message-box">
          <mx-icon class="icon"></mx-icon>
          <span class="message"></span>
        </div>
      `;

      const cssTemplate = document.createElement("template");
      cssTemplate.innerHTML = `
        <style>
          ${Object.keys(colors)
            .map((type) => {
              return `
                :host([type='${type}']) {
                  --text-color: var(--${type}-color);
                  --bg-color: var(--${type}-color-light-7);
                  --border-color: var(--${type}-color-light-4);
                }
              `;
            })
            .join("\n")}
          
          .message-box {
            max-width: 300px;
            font-size: 14px;
            display: none;
            align-items: center;
            gap: 8px;
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translate(-50%, 20px);
            opacity: 0;
            background-color: var(--bg-color);
            color: var(--text-color);
            border: 1px solid var(--border-color);
            padding: 10px 15px;
            border-radius: 5px;
            z-index: 100;
            
          }
          .message-box.show {
            transform: translate(-50%, 0);
            opacity: 1;
            transition: transform 0.3s ease, opacity 0.3s ease;
          }
          .message-box.hide {
            transform: translate(-50%, -20px);
            opacity: 0;
            transition: transform 0.6s ease, opacity 0.6s ease;
          }
        
        </style>
      `;

      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(
        htmlTemplate.content,
        commonCssTemplate.content.cloneNode(true),
        cssTemplate.content,
      );

      this.box = this.shadowRoot.querySelector(".message-box");
      this.icon = this.shadowRoot.querySelector(".icon");
      this.message = this.shadowRoot.querySelector(".message");
    }

    connectedCallback() {
      this.box.addEventListener("transitionend", (e) => {
        if (this.box.classList.contains("hide")) {
          this.box.style.display = "none";
          this.box.classList.remove("hide");
        }
      });

      this.message.addEventListener("click", (e) => {
        navigator.clipboard.writeText(e.target.textContent);
      });
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
      if (attrName === "type") {
        const map = {
          primary: "info",
          success: "success",
          info: "info",
          warning: "warning",
          danger: "close",
        };
        const iconType = map[newVal];
        this.icon.setAttribute("type", iconType);
      }
    }

    static get instance() {
      if (!MessageBox.#instance) {
        const el = document.createElement("mx-message-box");
        document.documentElement.appendChild(el);
        MessageBox.#instance = el;
      }
      return MessageBox.#instance;
    }

    #show(message, type = "info", duration) {
      const calcDuration = (message) => {
        // 最小 2 秒, 最大 5 秒, 基础 0.5 秒, 每个字符 50 ms
        const [min, max, base, perChar] = [2000, 5000, 500, 50];
        const lengthTime = message.length * perChar;

        return Math.min(max, Math.max(min, base + lengthTime));
      };

      this.setAttribute("type", type);
      this.message.textContent = message; // 设置信息
      this.message.title = message;

      this.box.style.display = "flex";

      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          this.box.classList.add("show");
        });
      });

      clearTimeout(this._hideTimer);
      this._hideTimer = setTimeout(
        () => {
          this.box.classList.remove("show");
          this.box.classList.add("hide");
        },
        duration || calcDuration(message),
      );
    }

    primary(message, duration) {
      this.#show(message, "primary", duration);
    }
    info(message, duration) {
      this.#show(message, "info", duration);
    }
    success(message, duration) {
      this.#show(message, "success", duration);
    }
    error(message, duration) {
      this.#show(message, "danger", duration);
    }
    warning(message, duration) {
      this.#show(message, "warning", duration);
    }
  }

  class Dialog extends HTMLElement {
    visible = false;
    #confirmBtn = null;
    #cancelBtn = null;
    #closeBtn = null;

    static get observedAttributes() {
      return ["cancel-text", "confirm-text"];
    }

    constructor() {
      super();

      const htmlTemplate = document.createElement("template");
      htmlTemplate.innerHTML = `
        <main>
          <header>
            <slot name="header"></slot>
            
            <button class="close">✕</button>
          </header>
          
          <article>
            <slot></slot>
          </article>
          
          <footer>
            <slot name="footer">
              <slot name="button-before"></slot>
              <mx-button class="cancel">取消</mx-button>
              <slot name="button-center"></slot>
              <mx-button class="confirm" type="primary">确认</mx-button>
              <slot name="button-after"></slot>
            </slot>
          </footer>
        </main>
        
        <div class="mask"></div>
      `;

      const cssTemplate = document.createElement("template");
      cssTemplate.innerHTML = `
        <style>
          :host {
            display: none;
          }
          
          main {
            min-width: 500px;
            padding: 16px;
            position: fixed;
            left: 50%;
            top: calc(20vh);
            transform: translateX(-50%);
            z-index: 3001;
            border-radius: 4px;
            background-color: var(--bg-color);
            color: var(--text-color);
            box-shadow: var(--box-shadow);
          }
          
          header {
            padding-bottom: 16px;
            font-size: 18px;
          }
          
          article {
            min-width: 500px;
          }
          
          footer {
            display: flex;
            justify-content: flex-end;
            gap: 12px;
            padding-top: 16px;
          }
        
          .close {
            font-size: 16px;
            aspect-ratio: 1/1;
            padding: 0;
            position: fixed;
            top: 16px;
            right: 16px;
            background-color: inherit;
            border: 0;
          }
          .close:hover {
            color: #F56C6C;
          }
          
          .mask {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            z-index: 3000;
            background: var(--overlay-bg);
          }
        </style>
      `;

      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(
        htmlTemplate.content,
        commonCssTemplate.content.cloneNode(true),
        cssTemplate.content,
      );
      this.#confirmBtn = this.shadowRoot.querySelector(".confirm");
      this.#cancelBtn = this.shadowRoot.querySelector(".cancel");
      this.#closeBtn = this.shadowRoot.querySelector(".close");
    }

    connectedCallback() {
      // 按钮文字
      {
        const cancelText = this.getAttribute("cancel-text") || "取消";
        const confirmText = this.getAttribute("confirm-text") || "确认";
        this.#cancelBtn.textContent = cancelText;
        this.#confirmBtn.textContent = confirmText;
      }

      // 事件初始化
      {
        // 提交按钮
        this.#confirmBtn?.addEventListener("click", (e) => {
          this.visible = false;
          this.style.display = "none";
          this.dispatchEvent(new CustomEvent("confirm"));
        });

        const cancel = () => {
          this.visible = false;
          this.style.display = "none";
          this.dispatchEvent(new CustomEvent("cancel"));
        };

        // 关闭按钮
        this.#cancelBtn?.addEventListener("click", cancel);
        this.#closeBtn?.addEventListener("click", cancel);

        // ESC 键盘事件
        document.addEventListener("keydown", (e) => {
          if (e.key === "Escape" && this.visible) {
            cancel();
          }
        });
      }
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === "visible" && oldValue !== newValue) {
        this.style.display = newValue !== null ? "block" : "none";
      }
    }

    open() {
      this.visible = true;
      this.style.display = "block";
      this.dispatchEvent(new CustomEvent("open"));
    }
  }

  class Icon extends HTMLElement {
    #paths = {
      info: "M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.344s-27.072-57.344-60.288-57.344c-33.28 0-60.16 23.104-60.16 57.344s26.88 57.344 60.16 57.344M590.912 699.2c0-6.848 2.368-24.64 1.024-34.752l-52.608 60.544c-10.88 11.456-24.512 19.392-30.912 17.28a12.992 12.992 0 0 1-8.256-14.72l87.68-276.992c7.168-35.136-12.544-67.2-54.336-71.296-44.096 0-108.992 44.736-148.48 101.504 0 6.784-1.28 23.68.064 33.792l52.544-60.608c10.88-11.328 23.552-19.328 29.952-17.152a12.8 12.8 0 0 1 7.808 16.128L388.48 728.576c-10.048 32.256 8.96 63.872 55.04 71.04 67.84 0 107.904-43.648 147.456-100.416z",
      success:
        "M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.272 38.272 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336z",
      warning:
        "M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.432 58.432 0 0 0-58.24 63.744l23.36 256.384a35.072 35.072 0 0 0 69.76 0l23.296-256.384A58.432 58.432 0 0 0 512 256m0 512a51.2 51.2 0 1 0 0-102.4 51.2 51.2 0 0 0 0 102.4",
      close:
        "M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 393.664L407.936 353.6a38.4 38.4 0 1 0-54.336 54.336L457.664 512 353.6 616.064a38.4 38.4 0 1 0 54.336 54.336L512 566.336 616.064 670.4a38.4 38.4 0 1 0 54.336-54.336L566.336 512 670.4 407.936a38.4 38.4 0 1 0-54.336-54.336z",
    };

    static observedAttributes = ["type"];

    constructor() {
      super();

      const htmlTemplate = document.createElement("template");
      htmlTemplate.innerHTML = `<svg viewBox="0 0 1024 1024"><path d=""></path></svg>`;

      const cssTemplate = document.createElement("template");
      cssTemplate.innerHTML = `
        <style>
          :host {
            display: inline-block;
            width: 1em;
            height: 1em;
            color: currentColor;
          }
          svg {
            width: 100%;
            height: 100%;
            fill: currentColor;
          }
        </style>
      `;

      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(
        htmlTemplate.content,
        commonCssTemplate.content.cloneNode(true),
        cssTemplate.content,
      );

      this.path = this.shadowRoot.querySelector("path");
    }

    connectedCallback() {}

    attributeChangedCallback(attributeName, oldValue, newValue) {
      if (attributeName === "type") {
        this.toggle();
      }
    }

    toggle() {
      if (this.hasAttribute("type")) {
        this.type = this.getAttribute("type");

        if (this.type in this.#paths) {
          this.path.setAttribute("d", this.#paths[this.type]);
        } else {
          console.warn("出现未知的 icon 类型", this);
        }
      }
    }
  }

  // 注册组件
  [Input, Select, Button, Option, Switch, MessageBox, Dialog, Icon].forEach(
    (n) => {
      const name = `mx-${n.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`;

      if (!customElements.get(name)) {
        customElements.define(name, n);
      } else {
        console.error(`${name} 组件已注册`);
      }
    },
  );

  window.MxMessageBox = MessageBox.instance;
})();