// ==UserScript==
// @name 通用组件库
// @namespace https://gf.qytechs.cn/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;
})();