Hitomi - 页数过滤器

在右下角添加一个悬浮按钮,点击后弹出设置页数范围的对话框。UI现代简洁,兼容性强,支持桌面和手机。

// ==UserScript==
// @name         Hitomi - 页数过滤器
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  在右下角添加一个悬浮按钮,点击后弹出设置页数范围的对话框。UI现代简洁,兼容性强,支持桌面和手机。
// @author       You (-Refined by AI)
// @match        https://hitomi.la/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // --- 配置区 ---
    const DEFAULT_MIN_PAGES = 1;
    const DEFAULT_MAX_PAGES = 800;
    // --- 配置区结束 ---

    let currentMinPages = DEFAULT_MIN_PAGES;
    let currentMaxPages = DEFAULT_MAX_PAGES;

    /**
     * 创建并注入全新的UI(一个悬浮按钮和一个<dialog>对话框)
     */
    function createFilterUI() {
        // 1. 创建悬浮触发按钮
        const triggerButton = document.createElement('button');
        triggerButton.id = 'hf-open-dialog-btn';
        triggerButton.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
            </svg>
            <span>过滤</span>`;
        document.body.appendChild(triggerButton);

        // 2. 创建 <dialog> 对话框
        const dialog = document.createElement('dialog');
        dialog.id = 'hf-filter-dialog';
        dialog.innerHTML = `
            <div class="hf-dialog-content">
                <h3>页数范围过滤</h3>
                <p>请输入要显示的页数范围,留空表示不限制。</p>
                <div class="hf-input-group">
                    <input type="tel" pattern="[0-9]*" id="hf-min-pages" placeholder="最小页数">
                    <span>-</span>
                    <input type="tel" pattern="[0-9]*" id="hf-max-pages" placeholder="最大页数">
                </div>
                <div class="hf-button-group">
                    <button id="hf-apply-btn" class="hf-btn-primary">应用</button>
                    <button id="hf-close-btn" class="hf-btn-secondary">关闭</button>
                </div>
                <div id="hf-status"></div>
            </div>
        `;
        document.body.appendChild(dialog);

        // 3. 注入 CSS 样式
        const style = document.createElement('style');
        style.innerHTML = `
            /* --- 悬浮按钮样式 --- */
            #hf-open-dialog-btn {
                position: fixed;
                bottom: 20px;
                right: 20px;
                z-index: 99998;
                width: 60px;
                height: 60px;
                border-radius: 50%;
                background-color: #3a87ad;
                color: white;
                border: none;
                box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                cursor: pointer;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                gap: 2px;
                transition: transform 0.2s, background-color 0.2s;
            }
            #hf-open-dialog-btn:hover {
                background-color: #2c6a8a;
                transform: scale(1.05);
            }
            #hf-open-dialog-btn span { font-size: 12px; }

            /* --- Dialog 对话框样式 --- */
            #hf-filter-dialog {
                width: 90vw;
                max-width: 320px;
                border: 1px solid #ddd;
                border-radius: 12px;
                box-shadow: 0 10px 30px rgba(0,0,0,0.2);
                padding: 0; /* 我们在内部容器控制padding */
                margin: auto;
            }
            #hf-filter-dialog::backdrop { /* 对话框蒙层样式 */
                background-color: rgba(0, 0, 0, 0.5);
                backdrop-filter: blur(2px);
            }
            #hf-filter-dialog .hf-dialog-content {
                padding: 20px;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            #hf-filter-dialog h3 { margin: 0 0 8px; font-size: 1.2em; text-align: center; color: #333; }
            #hf-filter-dialog p { margin: 0 0 16px; font-size: 0.9em; color: #666; text-align: center; }

            #hf-filter-dialog .hf-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
            #hf-filter-dialog .hf-input-group input {
                width: 100%;
                padding: 10px;
                border: 1px solid #ccc;
                border-radius: 8px;
                font-size: 1em;
                text-align: center;
                -moz-appearance: textfield;
            }
            #hf-filter-dialog .hf-input-group input::-webkit-outer-spin-button,
            #hf-filter-dialog .hf-input-group input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

            #hf-filter-dialog .hf-button-group { display: flex; gap: 10px; }
            #hf-filter-dialog button {
                width: 100%;
                padding: 12px;
                border-radius: 8px;
                border: none;
                font-size: 1em;
                font-weight: bold;
                cursor: pointer;
                transition: opacity 0.2s;
            }
            #hf-filter-dialog button:hover { opacity: 0.85; }
            #hf-filter-dialog .hf-btn-primary { background-color: #3a87ad; color: white; }
            #hf-filter-dialog .hf-btn-secondary { background-color: #e0e0e0; color: #333; }

            #hf-status { margin-top: 15px; text-align: center; font-size: 0.8em; color: #777; min-height: 1.2em; }
        `;
        document.head.appendChild(style);

        // 4. 绑定事件
        triggerButton.addEventListener('click', () => dialog.showModal());
        document.getElementById('hf-close-btn').addEventListener('click', () => dialog.close());
        document.getElementById('hf-apply-btn').addEventListener('click', () => {
            const minInput = document.getElementById('hf-min-pages');
            const maxInput = document.getElementById('hf-max-pages');

            minInput.value = minInput.value.replace(/[^0-9]/g, '');
            maxInput.value = maxInput.value.replace(/[^0-9]/g, '');

            let newMin = parseInt(minInput.value, 10) || 1;
            let newMax = parseInt(maxInput.value, 10) || Infinity;

            if (newMin > newMax) {
                [newMin, newMax] = [newMax, newMin];
                minInput.value = newMin;
                maxInput.value = (newMax === Infinity) ? '' : newMax;
            }

            saveSettings(newMin, newMax);
            applyFilterNow();
            dialog.close(); // 应用后自动关闭
        });
    }

    // ... (saveSettings, loadSettings, applyFilterNow, main 函数与之前版本相同) ...
    // ... 将版本 3.5 的 saveSettings, loadSettings, applyFilterNow, main 函数完整复制到这里 ...
    function saveSettings(min, max) {
        currentMinPages = min;
        currentMaxPages = max;
        localStorage.setItem('hf_min_pages', min);
        localStorage.setItem('hf_max_pages', max === Infinity ? '' : max);
    }

    function loadSettings() {
        const savedMin = localStorage.getItem('hf_min_pages');
        const savedMax = localStorage.getItem('hf_max_pages');
        currentMinPages = parseInt(savedMin, 10) || DEFAULT_MIN_PAGES;
        currentMaxPages = parseInt(savedMax, 10) || Infinity;

        const minInput = document.getElementById('hf-min-pages');
        if (minInput) minInput.value = currentMinPages > 1 ? currentMinPages : '';
        const maxInput = document.getElementById('hf-max-pages');
        if (maxInput) maxInput.value = (currentMaxPages === Infinity) ? '' : currentMaxPages;
    }

    function applyFilterNow() {
        const statusElement = document.getElementById('hf-status');
        const galleryItems = document.querySelectorAll('.gallery-content > div[class]');

        if (galleryItems.length === 0) {
            if (statusElement) statusElement.textContent = '未找到作品';
            return;
        }

        let visibleCount = 0;
        let totalCount = 0;
        galleryItems.forEach(item => {
            const h1 = item.querySelector('h1.lillie');
            if (!h1) return;

            totalCount++;
            item.style.display = '';

            let pages = -1;
            const firstNode = h1.childNodes[0];
            if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
                const match = firstNode.textContent.trim().match(/^\((\d+)\)$/);
                if (match) pages = parseInt(match[1], 10);
            }

            if (pages === -1) {
                const pageInfoTd = Array.from(item.querySelectorAll('td')).find(td => /pages/i.test(td.textContent));
                if(pageInfoTd) {
                    const match = pageInfoTd.textContent.match(/(\d+)\s*pages/i);
                    if (match) pages = parseInt(match[1], 10);
                }
            }

            if (pages !== -1) {
                if (pages < currentMinPages || pages > currentMaxPages) {
                    item.style.display = 'none';
                } else {
                    visibleCount++;
                }
            } else {
                visibleCount++;
            }
        });

        if (statusElement) statusElement.textContent = `显示 ${visibleCount} / ${totalCount} 项`;
    }

    function main() {
        createFilterUI(); // 注意,这里调用的是新函数
        loadSettings();

        let initialFilterDone = false;
        const tryInitialFilter = (retries = 0) => {
            if (document.querySelector('.gallery-content')) {
                applyFilterNow();
                initialFilterDone = true;
            } else if (retries < 20) {
                setTimeout(() => tryInitialFilter(retries + 1), 500);
            }
        };
        tryInitialFilter();

        const observer = new MutationObserver((mutations) => {
            if (!initialFilterDone) return;
            const hasNewNodes = mutations.some(mutation => mutation.addedNodes.length > 0);
            if (hasNewNodes) {
                clearTimeout(observer.timer);
                observer.timer = setTimeout(applyFilterNow, 300);
            }
        });

        const contentParent = document.querySelector('.container') || document.body;
        observer.observe(contentParent, { childList: true, subtree: true });
    }

    if (document.body) {
        main();
    } else {
        window.addEventListener('DOMContentLoaded', main, { once: true });
    }

})();