// ==UserScript==
// @name 珠宝AI网页适配
// @namespace https://zbai.art/
// @version 0.2.3
// @description Make Zbai image creation controls reachable on mobile browsers.
// @match http://zbai.art/*
// @match https://zbai.art/*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const MOBILE_QUERY = "(max-width: 820px)";
const TOUCH_QUERY = "(pointer: coarse)";
const ROOT_ID = "zbai-mobile-root";
const CLASS = {
enabled: "zbai-mobile-enabled",
compose: "zbai-mobile-compose-source",
composeOpen: "zbai-mobile-compose-open",
formatChoice: "zbai-mobile-format-choice",
formatLabel: "zbai-mobile-format-label",
formatRow: "zbai-mobile-format-row",
history: "zbai-mobile-history-source",
historyOpen: "zbai-mobile-history-open",
lock: "zbai-mobile-lock",
};
let activeSurface = "";
let toastTimer = 0;
addStyles();
waitForBody(boot);
function boot() {
if (!isSupportedRoute() || document.getElementById(ROOT_ID)) return;
const root = buildRoot();
document.body.append(root);
syncMode();
refreshTargets();
root.addEventListener("click", onRootClick);
listenForModeChange(MOBILE_QUERY);
listenForModeChange(TOUCH_QUERY);
window.addEventListener("resize", throttle(syncMode, 180));
document.addEventListener("click", delayPromoteFloatingMenus, true);
const observer = new MutationObserver(throttle(refreshTargets, 180));
observer.observe(document.body, { attributes: true, childList: true, subtree: true });
}
function buildRoot() {
const root = document.createElement("div");
root.id = ROOT_ID;
root.innerHTML = `
手机脚本已启用
`;
return root;
}
function onRootClick(event) {
const button = event.target.closest("[data-zbai-action]");
if (!button || !isMobile()) return;
const action = button.dataset.zbaiAction;
if (action === "close") closeSurface();
if (action === "compose") toggleCompose();
if (action === "upload") openUpload();
if (action === "history") toggleHistory();
if (action === "download") openDownload();
if (action === "auth") toggleAuth();
}
function toggleCompose() {
const panel = findComposePanel();
if (!panel) {
toast("还没找到生成面板,页面加载完再点一次");
return;
}
if (activeSurface === "compose") {
closeSurface();
return;
}
openSurface("compose", panel);
focusPrompt(panel);
}
async function openUpload() {
const panel = findComposePanel();
if (!panel) {
toast("还没找到上传区,先进入图片创作页");
return;
}
openSurface("compose", panel);
let upload = findUploadControl(panel);
if (!upload) {
findClickableByText(panel, "图生")?.click();
await delay(240);
upload = findUploadControl(findComposePanel() || panel);
}
if (!upload) {
toast("已打开图生面板,请在面板里点上传");
return;
}
upload.scrollIntoView({ block: "center", behavior: "smooth" });
upload.click();
}
function toggleHistory() {
const rail = findHistoryRail();
if (!rail) {
toast("还没找到历史记录列");
return;
}
if (activeSurface === "history") {
closeSurface();
return;
}
openSurface("history", rail);
rail.scrollIntoView({ block: "nearest", inline: "nearest" });
}
async function openDownload() {
closeSurface();
const download = findDownloadControl();
if (download) {
download.click();
return;
}
const result = findPrimaryResult();
if (!result) {
toast("先打开或生成一张图片,再点下载");
return;
}
result.click();
await delay(280);
const previewDownload = findDownloadControl();
if (previewDownload) {
previewDownload.click();
return;
}
toast("预览已打开,请点预览里的下载图标");
}
function openSurface(surface, node) {
closeSurface();
activeSurface = surface;
node.classList.add(surface === "compose" ? CLASS.composeOpen : CLASS.historyOpen);
if (surface === "compose") node.scrollTop = 0;
document.body.classList.add(CLASS.lock);
document.getElementById(ROOT_ID)?.setAttribute("data-zbai-surface", surface);
}
function closeSurface() {
activeSurface = "";
document.querySelector(`.${CLASS.composeOpen}`)?.classList.remove(CLASS.composeOpen);
document.querySelector(`.${CLASS.historyOpen}`)?.classList.remove(CLASS.historyOpen);
document.body.classList.remove(CLASS.lock);
document.getElementById(ROOT_ID)?.removeAttribute("data-zbai-surface");
}
function refreshTargets() {
if (!isMobile() || !isSupportedRoute()) return;
refreshRoute();
refreshAuthControl();
const compose = findComposePanel();
compose?.classList.add(CLASS.compose);
markFormatControls(compose);
findHistoryRail()?.classList.add(CLASS.history);
promoteFloatingMenus();
}
function refreshRoute() {
const root = document.getElementById(ROOT_ID);
if (!root) return;
root.dataset.zbaiRoute = findComposePanel() || isCreateRoute() ? "compose" : "page";
}
function refreshAuthControl() {
const button = document.querySelector(`#${ROOT_ID} .zbai-mobile-auth`);
if (!button) return;
const loggedIn = isLoggedIn();
button.textContent = loggedIn ? "退出" : "登录";
button.setAttribute("aria-label", loggedIn ? "退出登录" : "登录");
}
function syncMode() {
const enabled = isMobile();
document.body.classList.toggle(CLASS.enabled, enabled);
if (!enabled) closeSurface();
refreshTargets();
}
function listenForModeChange(query) {
const media = window.matchMedia(query);
const onChange = () => syncMode();
if (typeof media.addEventListener === "function") media.addEventListener("change", onChange);
else media.addListener?.(onChange);
}
function delayPromoteFloatingMenus() {
window.setTimeout(promoteFloatingMenus, 60);
}
function promoteFloatingMenus() {
if (!isMobile() || !activeSurface) return;
const menus = document.querySelectorAll([
".ant-select-dropdown",
".ant-dropdown",
".arco-select-popup",
".el-select-dropdown",
"[role='listbox']",
"[class*='select'][class*='dropdown']",
"[class*='dropdown']",
"[class*='popover']",
"[class*='popper']",
].join(","));
[...menus].filter(isFloatingMenu).forEach((node) => {
if (node.style.getPropertyValue("z-index") !== "2147483605") {
node.style.setProperty("z-index", "2147483605", "important");
}
});
}
function isFloatingMenu(node) {
if (!node || node.closest(`#${ROOT_ID}`)) return false;
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
const position = style.position === "absolute" || style.position === "fixed";
return position && rect.width > 0 && rect.height > 0 &&
style.display !== "none" && style.visibility !== "hidden";
}
async function toggleAuth() {
if (isLoggedIn()) {
await openLogout();
return;
}
openLogin();
}
function openLogin() {
const login = findAuthTextControl(/^(登录|登录\/注册|注册\/登录)$/) ||
findAuthTextControl(/登录/);
if (!login) {
toast("还没找到原站登录入口,刷新首页后再点");
return;
}
login.click();
}
async function openLogout() {
let logout = findAuthTextControl(/退出登录|退出账号|退出/);
if (logout) {
logout.click();
return;
}
const avatar = findProfileAvatar();
if (!avatar) {
toast("还没找到头像入口,刷新首页后再点");
return;
}
avatar.click();
await delay(260);
logout = findAuthTextControl(/退出登录|退出账号|退出/);
if (logout) {
logout.click();
return;
}
toast("账号菜单已打开,请点退出");
}
function isLoggedIn() {
return Boolean(findProfileAvatar());
}
function findProfileAvatar() {
const candidates = document.querySelectorAll(
'img[src*="avatar" i], img[src*="profile" i], img[src*="head" i]'
);
return [...candidates].find((image) => {
if (!isUsable(image)) return false;
const rect = image.getBoundingClientRect();
const src = image.currentSrc || image.src || "";
const style = getComputedStyle(image);
const avatarSource = /avatar|profile|head|default_profile_picture/i.test(src);
const headerAvatarShape = rect.top < 120 && rect.right > window.innerWidth * 0.66 &&
rect.width >= 24 && rect.width <= 72 && rect.height >= 24 && rect.height <= 72 &&
style.cursor === "pointer";
return avatarSource && headerAvatarShape;
});
}
function findAuthTextControl(pattern) {
return [...document.querySelectorAll(
"button, a, [role='button'], [role='menuitem'], li, div, span"
)]
.filter((node) => !node.closest(`#${ROOT_ID}`))
.find((node) => {
const text = normalizedText(node);
return text.length <= 16 && pattern.test(text);
});
}
function findComposePanel() {
const anchors = [
...document.querySelectorAll("textarea"),
...document.querySelectorAll('input[type="file"]'),
...[...document.querySelectorAll("button, label, [role='button']")]
.filter((node) => normalizedText(node).includes("上传")),
];
for (const anchor of anchors) {
const panel = walkParents(anchor, isComposePanel);
if (panel) return panel;
}
return null;
}
function isComposePanel(node) {
const text = normalizedText(node);
const hasGeneratorConfig = text.includes("选择模型") ||
text.includes("选择模板") ||
text.includes("选个风格");
const hasCreate = text.includes("立即创作") || text.includes("开始创作");
const hasUpload = text.includes("上传") || Boolean(node.querySelector?.('input[type="file"]'));
return hasGeneratorConfig && hasCreate && hasUpload;
}
function markFormatControls(panel) {
if (!panel) return;
const labels = [...panel.querySelectorAll("div, span, p, label")]
.filter((node) => normalizedText(node) === "多格式");
labels.forEach((label) => {
label.classList.add(CLASS.formatLabel);
const row = walkParents(label, (node) => isFormatRow(node, panel));
if (!row) return;
row.classList.add(CLASS.formatRow);
markFormatChoices(row);
});
}
function isFormatRow(node, panel) {
if (!node || node === panel) return false;
const text = normalizedText(node);
return text.includes("多格式") && text.includes("JPEG") && text.includes("PNG") &&
text.length <= 80;
}
function markFormatChoices(row) {
[...row.querySelectorAll("button, label, div, span, p")]
.filter((node) => /^(JPEG|PNG)$/.test(normalizedText(node)))
.forEach((node) => {
node.classList.add(CLASS.formatChoice);
const parent = node.parentElement;
if (parent && parent !== row && normalizedText(parent).length <= 20) {
parent.classList.add(CLASS.formatChoice);
}
});
}
function findPromptInput(scope) {
return scope.querySelector(
'textarea[placeholder*="描述"], textarea[placeholder*="重绘"], textarea'
);
}
function findUploadControl(scope) {
return findClickableByText(scope, "上传参考图") ||
scope.querySelector('input[type="file"]') ||
scope.querySelector('[class*="upload" i]');
}
function focusPrompt(panel) {
const prompt = findPromptInput(panel);
if (!prompt) return;
prompt.scrollIntoView({ block: "center" });
prompt.focus({ preventScroll: true });
}
function findHistoryRail() {
const images = [...document.images].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.left < 160 && rect.width > 24 && rect.width < 130 && rect.height > 24;
});
for (const image of images) {
const rail = walkParents(image, (node) => {
const rect = node.getBoundingClientRect();
const imageCount = node.querySelectorAll("img").length;
return imageCount >= 2 && rect.height > 220 && rect.width > 44 && rect.width < 190;
});
if (rail) return rail;
}
return null;
}
function findDownloadControl() {
const labeled = [
'[title*="下载"]',
'[aria-label*="下载"]',
'[download]',
'[class*="download" i]',
].flatMap((selector) => [...document.querySelectorAll(selector)]);
const byText = [...document.querySelectorAll("button, a, [role='button']")].filter((node) =>
normalizedText(node).includes("下载")
);
const byRightPreviewIcon = [...document.querySelectorAll("button, a, [role='button'], i, span")]
.filter(isLikelyPreviewDownload);
return [...labeled, ...byText, ...byRightPreviewIcon].find(isUsable);
}
function findPrimaryResult() {
return [...document.images]
.map((image) => ({ image, rect: image.getBoundingClientRect() }))
.filter(({ image, rect }) => {
const src = image.currentSrc || image.src || "";
return isUsable(image) && rect.width > 150 && rect.height > 150 &&
!src.includes("logo") && !src.includes("bg-");
})
.sort((a, b) => b.rect.width * b.rect.height - a.rect.width * a.rect.height)
.map(({ image }) => image)[0];
}
function isLikelyPreviewDownload(node) {
const rect = node.getBoundingClientRect();
if (!isUsable(node) || rect.left < window.innerWidth * 0.62 || rect.top > window.innerHeight * 0.66) {
return false;
}
const signature = [
node.className,
node.getAttribute("title"),
node.getAttribute("aria-label"),
node.innerHTML,
].join(" ");
return /download|xiazai|icon-download||/i.test(signature) &&
!normalizedText(node).includes("上传");
}
function findClickableByText(scope, text) {
return [...scope.querySelectorAll("button, a, label, [role='button']")]
.find((node) => normalizedText(node).includes(text) && isUsable(node));
}
function walkParents(start, predicate) {
let node = start;
for (let depth = 0; node && depth < 18; depth += 1, node = node.parentElement) {
if (predicate(node)) return node;
}
return null;
}
function normalizedText(node) {
return (node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim();
}
function isUsable(node) {
if (!node || node.closest(`#${ROOT_ID}`)) return false;
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return rect.width > 0 && rect.height > 0 &&
style.visibility !== "hidden" && style.display !== "none";
}
function toast(message) {
const box = document.querySelector(`#${ROOT_ID} .zbai-mobile-toast`);
if (!box) return;
box.textContent = message;
box.classList.add("is-visible");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => box.classList.remove("is-visible"), 2600);
}
function addStyles() {
const css = `
#${ROOT_ID} { display: none; }
body.${CLASS.enabled}.${CLASS.lock} { overflow: hidden !important; }
body.${CLASS.enabled} #${ROOT_ID} {
display: block;
position: fixed;
inset: 0;
z-index: 2147483600;
pointer-events: none;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-auth {
position: fixed;
z-index: 2147483603;
top: max(12px, env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
min-width: 64px;
min-height: 40px;
padding: 0 14px;
border: 1px solid rgba(31, 35, 40, .14);
border-radius: 8px;
color: #fff;
background: rgba(23, 25, 31, .94);
box-shadow: 0 10px 28px rgba(19, 24, 33, .22);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
pointer-events: auto;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-close {
display: none;
position: fixed;
z-index: 2147483606;
top: max(10px, env(safe-area-inset-top));
left: max(10px, env(safe-area-inset-left));
min-height: 40px;
padding: 0 14px;
border: 1px solid rgba(31, 35, 40, .14);
border-radius: 8px;
color: #17191f;
background: rgba(255, 255, 255, .96);
box-shadow: 0 10px 28px rgba(19, 24, 33, .18);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
pointer-events: auto;
}
body.${CLASS.enabled} #${ROOT_ID}[data-zbai-surface] .zbai-mobile-close {
display: block;
}
body.${CLASS.enabled} #${ROOT_ID}:not([data-zbai-route="compose"]) .zbai-mobile-dock {
display: none;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-dock {
position: fixed;
z-index: 2147483602;
right: max(12px, env(safe-area-inset-right));
bottom: calc(12px + env(safe-area-inset-bottom));
left: max(12px, env(safe-area-inset-left));
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding: 8px;
border: 1px solid rgba(31, 35, 40, .12);
border-radius: 8px;
background: rgba(255, 255, 255, .94);
box-shadow: 0 12px 36px rgba(19, 24, 33, .18);
backdrop-filter: blur(12px);
pointer-events: auto;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-dock button {
min-width: 0;
min-height: 44px;
border: 0;
border-radius: 6px;
color: #17191f;
background: #f3f4f7;
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-dock button:first-child {
color: #fff;
background: #17191f;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-ready {
position: fixed;
z-index: 2147483603;
right: max(12px, env(safe-area-inset-right));
bottom: calc(78px + env(safe-area-inset-bottom));
padding: 5px 9px;
border: 1px solid rgba(31, 35, 40, .12);
border-radius: 7px;
color: #17191f;
background: rgba(255, 255, 255, .94);
box-shadow: 0 8px 22px rgba(19, 24, 33, .16);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
pointer-events: none;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-backdrop {
position: fixed;
inset: 0;
border: 0;
opacity: 0;
background: rgba(10, 12, 18, .34);
pointer-events: none;
transition: opacity .18s ease;
}
body.${CLASS.enabled} #${ROOT_ID}[data-zbai-surface] .zbai-mobile-backdrop {
opacity: 1;
pointer-events: auto;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-toast {
position: fixed;
z-index: 2147483603;
right: 18px;
bottom: calc(82px + env(safe-area-inset-bottom));
left: 18px;
padding: 11px 13px;
border-radius: 7px;
color: #fff;
background: rgba(23, 25, 31, .94);
font-size: 13px;
line-height: 1.4;
opacity: 0;
transform: translateY(8px);
transition: opacity .16s ease, transform .16s ease;
pointer-events: none;
}
body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} {
position: fixed !important;
z-index: 2147483601 !important;
inset: auto 0 0 0 !important;
width: auto !important;
min-width: 0 !important;
max-width: none !important;
height: min(88dvh, 860px) !important;
max-height: calc(100dvh - 12px) !important;
margin: 0 !important;
padding-bottom: calc(88px + env(safe-area-inset-bottom)) !important;
border-radius: 8px 8px 0 0 !important;
overflow: auto !important;
overscroll-behavior: contain;
background: #fff !important;
box-shadow: 0 -16px 42px rgba(19, 24, 33, .24) !important;
transform: none !important;
}
body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} textarea {
min-height: 108px !important;
}
body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} button {
min-height: 40px;
}
body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} .${CLASS.formatLabel} {
flex: 0 0 auto !important;
min-width: 48px !important;
white-space: nowrap !important;
word-break: keep-all !important;
writing-mode: horizontal-tb !important;
}
body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} .${CLASS.formatRow} {
width: 100% !important;
min-width: 0 !important;
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 8px !important;
pointer-events: auto !important;
}
body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} .${CLASS.formatRow} .${CLASS.formatChoice} {
flex: 0 0 auto !important;
min-width: 72px !important;
min-height: 40px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
touch-action: manipulation;
pointer-events: auto !important;
}
body.${CLASS.enabled} .ant-select-dropdown,
body.${CLASS.enabled} .ant-dropdown,
body.${CLASS.enabled} .el-select-dropdown,
body.${CLASS.enabled} [role="listbox"],
body.${CLASS.enabled} [class*="dropdown" i],
body.${CLASS.enabled} [class*="popover" i],
body.${CLASS.enabled} [class*="popper" i],
body.${CLASS.enabled} [class*="select-dropdown" i],
body.${CLASS.enabled} [data-radix-popper-content-wrapper] {
z-index: 2147483605 !important;
}
body.${CLASS.enabled} .${CLASS.history}.${CLASS.historyOpen} {
position: fixed !important;
z-index: 2147483601 !important;
inset: 0 auto 0 0 !important;
width: min(40vw, 148px) !important;
min-width: 92px !important;
height: 100dvh !important;
margin: 0 !important;
padding: 12px 8px calc(92px + env(safe-area-inset-bottom)) !important;
border-radius: 0 8px 8px 0 !important;
overflow: auto !important;
overscroll-behavior: contain;
background: #f8f8fb !important;
box-shadow: 10px 0 34px rgba(19, 24, 33, .22) !important;
transform: none !important;
}
body.${CLASS.enabled} .${CLASS.history}.${CLASS.historyOpen} img {
max-width: 100% !important;
height: auto !important;
}
`;
if (typeof GM_addStyle === "function") GM_addStyle(css);
else {
const style = document.createElement("style");
style.textContent = css;
document.documentElement.append(style);
}
}
function waitForBody(callback) {
if (document.body) {
callback();
return;
}
window.addEventListener("DOMContentLoaded", callback, { once: true });
}
function isCreateRoute() {
return location.pathname.startsWith("/create/");
}
function isHomeRoute() {
return location.pathname === "/" || location.pathname === "";
}
function isSupportedRoute() {
return true;
}
function isMobile() {
const isTouchDevice = navigator.maxTouchPoints > 0;
const mobileAgent = /Android|iPhone|iPad|iPod|Mobile|Lemur/i.test(navigator.userAgent);
const phoneSizedScreen = Math.min(screen.width || 0, screen.height || 0) <= 900;
return window.matchMedia(MOBILE_QUERY).matches ||
window.matchMedia(TOUCH_QUERY).matches ||
(isTouchDevice && (mobileAgent || phoneSizedScreen));
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function throttle(callback, wait) {
let timer = 0;
return () => {
if (timer) return;
timer = window.setTimeout(() => {
timer = 0;
callback();
}, wait);
};
}
})();