qqshow(优化版)

优化按钮布局与视觉体验,支持拖拽悬浮按钮管理自定义头像

目前為 2025-05-13 提交的版本,檢視 最新版本

// ==UserScript==
// @name         qqshow(优化版)
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  优化按钮布局与视觉体验,支持拖拽悬浮按钮管理自定义头像
// @author       whosyourdaddy
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @connect      https://qqshow.131.996h.cn
// @icon         http://milkywayidle.com/favicon.ico
// @license      MIT
// @grant        none
// ==/UserScript==

//样式
const css = `
.custom-mwi-avatar {
    width: 100%;
    height: 100%;
}

.floating-btn {
    position: fixed;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    background: transparent;
    display: flex;
    justify-content: center;
    align-items: center;
    cursor: move;
    z-index: 99999;
    font-size: 30px;
    text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
    transition: transform 0.3s ease;
    right: 20px;
    bottom: 20px;
}

.floating-btn.dragging {
    transform: scale(1.05);
}

.floating-panel {
    position: fixed;
    background: white;
    padding: 20px 25px;
    border-radius: 15px;
    box-shadow: 0 6px 25px rgba(0, 0, 0, 0.1);
    min-width: 350px;
    display: none;
    z-index: 99998;
    font-family: Arial, sans-serif;
    text-align: center;
}

.floating-panel.active {
    display: block;
}

.input-group {
    margin-bottom: 20px;
}

.input-group input {
    width: 100%;
    padding: 10px 15px;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    font-size: 14px;
}

.button-group {
    display: flex;
    justify-content: center;
    gap: 15px;
    margin-top: 25px;
}

.action-btn {
    padding: 10px 20px;
    border: none;
    border-radius: 8px;
    font-size: 14px;
    cursor: pointer;
    transition: background-color 0.3s, transform 0.2s;
}

.action-btn-primary {
    background-color: #3498db;
    color: white;
    box-shadow: 0 2px 5px rgba(52, 152, 219, 0.2);
}

.action-btn-secondary {
    background-color: #f5f5f5;
    color: #333;
    border: 1px solid #e0e0e0;
}

.action-btn:hover {
    transform: scale(1.02);
}

.action-btn-primary:hover {
    background-color: #2980b9;
}

.action-btn-secondary:hover {
    background-color: #f0f0f0;
}

.error-message {
    color: #e74c3c;
    font-size: 0.9em;
    margin-top: 15px;
    text-align: center;
}
`;

const InsertStyleSheet = (style) => {
    const s = new CSSStyleSheet();
    s.replaceSync(style);
    document.adoptedStyleSheets = [...document.adoptedStyleSheets, s];
};
InsertStyleSheet(css);

//原有工具函数
const HTML = (tagname, attrs, ...children) => {
    if (attrs === undefined) return document.createTextNode(tagname);
    const ele = document.createElement(tagname);
    for (const [key, value] of Object.entries(attrs)) {
        if (value === null || value === undefined) continue;
        key.startsWith('_') ? ele.addEventListener(key.slice(1), value) : ele.setAttribute(key, value);
    }
    children.forEach(child => child && ele.append(child));
    return ele;
};

// ------------------------ 核心逻辑(完全保留) ------------------------
const RemoteHost = "https://qqshow.131.996h.cn";
const AvatarPath = "/get-avatar.php";
const AvatarsPath = "/get-avatars.php";
const UploadPath = "/set-avatar.php";

let PlayerUsername = "";
let avatarCache;
let lastUpdated;
const expireTime = 3 * 60 * 60 * 1000;

class Lock {
    #queue = [];
    #count = 0;
    constructor(count) {
        this.#count = count;
        this.release = this.release.bind(this);
    }
    acquire() {
        if (this.#count > 0) {
            this.#count -= 1;
            return this.release;
        } else {
            const { promise, resolve } = Promise.withResolvers();
            this.#queue.push(resolve);
            return promise;
        }
    }
    release() {
        if (this.#queue.length > 0) {
            const front = this.#queue.shift();
            front(this.release);
        } else this.#count += 1;
    }
}

const ReqLock = new Lock(1);

const InitCache = () => {
    try {
        avatarCache = JSON.parse(window.localStorage.getItem("custom-avatar-cache") ?? "undefined");
        lastUpdated = JSON.parse(window.localStorage.getItem("custom-avatar-cache-updated") ?? "undefined");
    } catch (e) {
        avatarCache = undefined;
        lastUpdated = undefined;
    }
};
InitCache();

const SaveCache = () => {
    window.localStorage.setItem("custom-avatar-cache", JSON.stringify(avatarCache));
    window.localStorage.setItem("custom-avatar-cache-updated", JSON.stringify(lastUpdated));
};

const UpdateCache = async () => {
    const res = await fetch(`${RemoteHost}${AvatarsPath}`, { mode: "cors" });
    if (res.status === 200) {
        avatarCache = await res.json();
        lastUpdated = new Date().getTime();
        SaveCache();
        return true;
    } else return false;
};

const CheckCache = async (username) => {
    if (!lastUpdated || !avatarCache || (new Date().getTime() - lastUpdated >= expireTime)) {
        const cacheValid = await UpdateCache();
        if (cacheValid) return avatarCache[username];
        else return false;
    } else return avatarCache[username];
};

const GetCustomAvatar = async (username) => {
    const lock = await ReqLock.acquire();
    const result = await CheckCache(username);
    lock();
    return result;
};

const ReplaceHeaderAvatar = async () => {
    const characterInfoDiv = document.querySelector("div.Header_characterInfo__3ixY8:not([avatar-modified])");
    if (!characterInfoDiv) return;
    characterInfoDiv.setAttribute("avatar-modified", "");
    const username = characterInfoDiv.querySelector(":scope div.CharacterName_name__1amXp").dataset.name;
    if (!PlayerUsername) PlayerUsername = username;
    const avatarWrapperDiv = characterInfoDiv.querySelector(":scope div.Header_avatar__2RQgo");
    const avatarURL = await GetCustomAvatar(username);
if (avatarURL) { 
    avatarWrapperDiv.replaceChildren(
        HTML("img", { class: "custom-mwi-avatar", src: avatarURL })
    );
}
};

const ReplaceProfileAvatar = async () => {
    const profileDiv = document.querySelector("div.SharableProfile_modal__2OmCQ:not([avatar-modified])");
    if (!profileDiv) return;
    profileDiv.setAttribute("avatar-modified", "");
    const username = profileDiv.querySelector(":scope div.CharacterName_name__1amXp").dataset.name;
    const avatarWrapperDiv = profileDiv.querySelector(":scope div.FullAvatar_fullAvatar__3RB2h.FullAvatar_xlarge__1cmUN");
    const avatarURL = await GetCustomAvatar(username);
if (avatarURL) { 
    avatarWrapperDiv.replaceChildren(
        HTML("img", { class: "custom-mwi-avatar", src: avatarURL })
    );
}
};

const ReplacePartyMember = async () => {
    const slotDiv = document.querySelector("div.Party_partySlots__3zGeH:not([avatar-modified])");
    if (!slotDiv) return;
    slotDiv.setAttribute("avatar-modified", "");
    const username = slotDiv.querySelector(":scope div.CharacterName_name__1amXp").dataset.name;
    const avatarWrapperDiv = slotDiv.querySelector(":scope div.FullAvatar_fullAvatar__3RB2h.FullAvatar_large__fJGwX");
    const avatarURL = await GetCustomAvatar(username);
if (avatarURL) { 
    avatarWrapperDiv.replaceChildren(
        HTML("img", { class: "custom-mwi-avatar", src: avatarURL })
    );
}
};

const ReplaceCombatUnit = async () => {
    const unitDiv = document.querySelector("div.CombatUnit_combatUnit__1m3XT:not([avatar-modified])");
    if (!unitDiv) return;
    unitDiv.setAttribute("avatar-modified", "");
    const username = unitDiv.querySelector(":scope div.CombatUnit_name__1SlO1").textContent;
    const avatarWrapperDiv = unitDiv.querySelector(":scope div.FullAvatar_fullAvatar__3RB2h");
    const avatarURL = await GetCustomAvatar(username);
if (avatarURL) { 
    avatarWrapperDiv.replaceChildren(
        HTML("img", { class: "custom-mwi-avatar", src: avatarURL })
    );
}
};

const UploadAvatar = async () => {
    const URLInput = document.getElementById("custom-avatar-url-input").value;
    const errorSpan = document.getElementById("custom-avatar-upload-error");

    try {
        const toURL = new URL(URLInput);
        if (toURL.protocol !== "https:") {
            errorSpan.textContent = "输入的链接协议不是https";
            return;
        }
    } catch (e) {
        if (e instanceof TypeError) {
            errorSpan.textContent = "输入的链接不是有效的URL";
            return;
        } else console.error(e);
    }

    errorSpan.textContent = "准备上传";

    const res = await fetch(`${RemoteHost}${UploadPath}`, {
        method: "POST",
        mode: "cors",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            username: PlayerUsername,
            imageURL: URLInput,
        })
    });

    if (res.status === 200) {
        errorSpan.textContent = "成功上传";
        if (!avatarCache) avatarCache = {};
        avatarCache[PlayerUsername] = URLInput;
        SaveCache();
        RefreshAvatar();
    } else errorSpan.textContent = `上传失败:${res.status} ${await res.text()}`;
};

const ManualRefresh = async () => {
    const errorSpan = document.getElementById("custom-avatar-upload-error");
    avatarCache = undefined;
    lastUpdated = undefined;
    errorSpan.textContent = "准备刷新";
    try {
        await GetCustomAvatar(PlayerUsername);
    } catch (e) {
        errorSpan.textContent = "刷新时出现错误,请不要联系我";
    }
    errorSpan.textContent = "刷新完成";
    RefreshAvatar();
};

const ShowHelp = () => {
    const errorSpan = document.getElementById("custom-avatar-upload-error");
    errorSpan.textContent = "上传HTTPS的图片链接图片将被设置为牛牛头像";
};

//悬浮UI
let floatingBtn, floatingPanel;
let isDragging = false;
let startX, startY, startLeft, startTop;

const CreateFloatingUI = () => {
    floatingBtn = document.createElement('div');
    floatingBtn.className = 'floating-btn';
    floatingBtn.textContent = '🐄';

    floatingPanel = document.createElement('div');
    floatingPanel.className = 'floating-panel';
    floatingPanel.innerHTML = `
        <div class="input-group">
            <input type="url" id="custom-avatar-url-input" placeholder="输入HTTPS图片链接(如 https://example.com/avatar.png)">
        </div>
        <div class="button-group">
            <button id="upload-btn" class="action-btn action-btn-primary">上传头像</button>
            <button id="help-btn" class="action-btn action-btn-secondary">帮助</button>
            <button id="refresh-btn" class="action-btn action-btn-secondary">刷新缓存</button>
        </div>
        <div id="custom-avatar-upload-error" class="error-message"></div>
    `;

    document.body.appendChild(floatingBtn);
    document.body.appendChild(floatingPanel);

    floatingBtn.addEventListener('click', () => {
        floatingPanel.classList.toggle('active');
        UpdatePanelPosition();
    });

    document.getElementById('upload-btn').addEventListener('click', UploadAvatar);
    document.getElementById('help-btn').addEventListener('click', ShowHelp);
    document.getElementById('refresh-btn').addEventListener('click', ManualRefresh);

    // 拖拽功能
    floatingBtn.addEventListener('mousedown', (e) => {
        isDragging = true;
        startX = e.clientX;
        startY = e.clientY;
        startLeft = floatingBtn.offsetLeft;
        startTop = floatingBtn.offsetTop;
        floatingBtn.classList.add('dragging');
        e.preventDefault();
    });

    document.addEventListener('mousemove', (e) => {
        if (!isDragging) return;
        const newX = startLeft + (e.clientX - startX);
        const newY = startTop + (e.clientY - startY);
        const maxX = window.innerWidth - floatingBtn.offsetWidth;
        const maxY = window.innerHeight - floatingBtn.offsetHeight;
        floatingBtn.style.left = `${Math.max(0, Math.min(newX, maxX))}px`;
        floatingBtn.style.top = `${Math.max(0, Math.min(newY, maxY))}px`;
        UpdatePanelPosition();
    });

    document.addEventListener('mouseup', () => {
        if (isDragging) {
            isDragging = false;
            floatingBtn.classList.remove('dragging');
            localStorage.setItem('floatingBtnPosition', JSON.stringify({
                left: floatingBtn.style.left,
                top: floatingBtn.style.top
            }));
        }
    });

    // 加载位置
    const savedPosition = localStorage.getItem('floatingBtnPosition');
    if (savedPosition) {
        const { left, top } = JSON.parse(savedPosition);
        floatingBtn.style.left = left;
        floatingBtn.style.top = top;
    } else {
        floatingBtn.style.right = '20px';
        floatingBtn.style.bottom = '20px';
        floatingBtn.style.left = 'auto';
        floatingBtn.style.top = 'auto';
    }
};

const UpdatePanelPosition = () => {
    if (!floatingPanel.classList.contains('active')) {
        return;
    }
    const btnRect = floatingBtn.getBoundingClientRect();
    let left = btnRect.right + 10;
    let top = btnRect.top;
    if (left + 350 > window.innerWidth) {
        left = btnRect.left - 360;
    }
    if (top + floatingPanel.offsetHeight > window.innerHeight) {
        top = window.innerHeight - floatingPanel.offsetHeight - 20;
    }
    floatingPanel.style.left = `${left}px`;
    floatingPanel.style.top = `${top}px`;
};

// ------------------------ 其余代码(完全保留) ------------------------
const AddUploadInput = () => {
    const settingDiv = document.querySelector("div.SettingsPanel_profileTab__214Bj:not([avatar-upload-added])");
    if (!settingDiv) return;
    settingDiv.setAttribute("avatar-upload-added", "");
    const settingGrid = settingDiv.children[0];
    const frag = document.createDocumentFragment();
    frag.append(
        HTML("div", { class: "SettingsPanel_label__24LRD" }, "上传自定义头像"),
        HTML("div", { class: "SettingsPanel_value__2nsKD" },
            HTML("input", { id: "custom-avatar-url-input-settings", class: "SettingsPanel_value__2nsKD Input_input__2-t98", placeholder: "输入自定义头像的图床链接" }),
            HTML("button", { class: "Button_button__1Fe9z", _click: () => {
                const input = document.getElementById("custom-avatar-url-input-settings");
                document.getElementById("custom-avatar-url-input").value = input.value;
                UploadAvatar();
            } }, "上传"),
            HTML("button", { class: "Button_button__1Fe9z", _click: ShowHelp }, "帮助"),
            HTML("button", { class: "Button_button__1Fe9z", _click: ManualRefresh }, "刷新本地缓存"),
        ),
        HTML("div", { class: "SettingsPanel_label__24LRD" }),
        HTML("div", { class: "SettingsPanel_value__2nsKD" },
            HTML("span", { id: "custom-avatar-upload-error-settings" }),
        ),
    );
    settingGrid.insertBefore(frag, settingGrid.children[0]);
};

const OnMutate = (mutlist, observer) => {
    observer.disconnect();
    ReplaceHeaderAvatar();
    ReplaceProfileAvatar();
    ReplaceCombatUnit();
    ReplacePartyMember();
    AddUploadInput();
    observer.observe(document, { subtree: true, childList: true });
};

const observer = new MutationObserver(OnMutate);
observer.observe(document, { subtree: true, childList: true });

const RefreshAvatar = () => {
    document.querySelectorAll("[avatar-modified]").forEach(ele => ele.removeAttribute("avatar-modified"));
    OnMutate([], observer);
};

window.addEventListener('load', () => {
    CreateFloatingUI();
});