TMS Case Per-Case Smart Filter

Фильтр кейсов с индивидуальным выбором сочетаний параметров для каждого кейса и перетаскиваемой кнопкой, с сохранением состояния

当前为 2025-06-27 提交的版本,查看 最新版本

// ==UserScript==
// @name         TMS Case Per-Case Smart Filter
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  Фильтр кейсов с индивидуальным выбором сочетаний параметров для каждого кейса и перетаскиваемой кнопкой, с сохранением состояния
// @match        https://ingr.firetms.ru/p/kasko/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // --- Drag & Drop для кнопки ---
    function makeDraggable(btn, storageKey = 'tms-case-filter-btn-pos') {
        let offsetX, offsetY, isDragging = false, moved = false;

        // Восстановить позицию
        const saved = localStorage.getItem(storageKey);
        if (saved) {
            const {left, top} = JSON.parse(saved);
            btn.style.left = left;
            btn.style.top = top;
            btn.style.right = '';
            btn.style.bottom = '';
        } else {
            btn.style.right = '24px';
            btn.style.bottom = '24px';
        }

        btn.style.position = 'fixed';
        btn.style.userSelect = 'none';
        btn.style.width = '180px';
        btn.style.height = '40px';
        btn.style.fontSize = '16px';
        btn.style.background = '#1976d2';
        btn.style.color = '#fff';
        btn.style.border = 'none';
        btn.style.borderRadius = '6px';
        btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
        btn.style.cursor = 'pointer';
        btn.style.whiteSpace = 'nowrap';
        btn.style.textAlign = 'center';
        btn.style.lineHeight = '40px';
        btn.style.padding = '0';
        btn.style.resize = 'none';
        btn.style.display = 'block';

        btn.addEventListener('mousedown', function(e) {
            if (e.button !== 0) return; // Только ЛКМ
            isDragging = true;
            moved = false;
            offsetX = e.clientX - btn.getBoundingClientRect().left;
            offsetY = e.clientY - btn.getBoundingClientRect().top;
            document.body.style.userSelect = 'none';
        });

        document.addEventListener('mousemove', function(e) {
            if (!isDragging) return;
            moved = true;
            btn.style.left = (e.clientX - offsetX) + 'px';
            btn.style.top = (e.clientY - offsetY) + 'px';
            btn.style.right = '';
            btn.style.bottom = '';
        });

        document.addEventListener('mouseup', function(e) {
            if (isDragging) {
                isDragging = false;
                document.body.style.userSelect = '';
                localStorage.setItem(storageKey, JSON.stringify({
                    left: btn.style.left,
                    top: btn.style.top
                }));
            }
        });

        // Возвращаем функцию, чтобы узнать был ли drag, и функцию сброса moved
        return {
            wasMoved: () => moved,
            resetMoved: () => { moved = false; }
        };
    }

    // --- Сбор кейсов ---
    function getCases() {
        return Array.from(document.querySelectorAll('.run-case__item')).map((item, idx) => {
            const checkbox = item.querySelector('input[type="checkbox"].form-check-input.checkbox-title');
            if (!checkbox) return null;
            const paramsDiv = item.querySelector('.run-case__params');
            const paramsText = paramsDiv ? paramsDiv.textContent.trim().replace(/^Параметры:\s*/i, '') : '';
            const link = item.querySelector('a[href]');
            const name = link ? link.textContent.trim() : `Кейс #${idx+1}`;
            // Попробуем найти название кейса (текст после ссылки)
            let title = '';
            if (link && link.parentNode) {
                // Обычно название — это текст после ссылки
                const afterLink = link.parentNode.textContent.replace(link.textContent, '').trim();
                if (afterLink) title = afterLink;
            }
            // Если не нашли, fallback
            if (!title) {
                const nameDiv = item.querySelector('.run-case__name');
                if (nameDiv) title = nameDiv.textContent.trim();
            }
            const paramsObj = {};
            paramsText.split(';').forEach(pair => {
                const [k, v] = pair.split(':').map(s => s && s.trim());
                if (k && v) paramsObj[k] = v;
            });
            return {item, paramsText, paramsObj, name, title, link: link ? link.href : '', checkbox};
        }).filter(Boolean);
    }

    // --- Уникальные параметры и значения для каждого кейса ---
    function getCaseParamValues(cases) {
        const caseParams = {};
        cases.forEach(c => {
            if (!caseParams[c.name]) caseParams[c.name] = {};
            Object.entries(c.paramsObj).forEach(([k, v]) => {
                if (!caseParams[c.name][k]) caseParams[c.name][k] = new Set();
                caseParams[c.name][k].add(v);
            });
        });
        // Преобразуем Set в массив
        Object.keys(caseParams).forEach(caseName => {
            Object.keys(caseParams[caseName]).forEach(k => {
                caseParams[caseName][k] = Array.from(caseParams[caseName][k]);
            });
        });
        return caseParams;
    }

    // --- UI: Overlay с индивидуальным выбором сочетаний для каждого кейса ---
    function showOverlay(cases, caseParamValues, caseCombinations, onSave, caseTitles) {
        // Стили
        const style = document.createElement('style');
        style.textContent = `
        #tms-case-filter-modal {
            background: #fff; padding: 24px; border-radius: 8px; min-width: 60vw; max-width: 60vw; max-height: 80vh; overflow: hidden;
            box-shadow: 0 2px 16px rgba(0,0,0,0.2); margin: 40px auto 0 auto; position: relative;
            display: flex; flex-direction: column; align-items: stretch;
        }
        #tms-case-filter-overlay {
            position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
            background: rgba(0,0,0,0.5); z-index: 99999; display: flex; align-items: flex-start; justify-content: center;
        }
        #tms-case-filter-close {
            position: absolute; top: 8px; right: 12px; font-size: 32px; color: #888; cursor: pointer; font-weight: bold; background: none; border: none;
            line-height: 1;
        }
        #tms-case-filter-close:hover { color: #d33; }
        #tms-case-filter-cases-scroll {
            flex: 1 1 auto;
            overflow-y: auto;
            max-height: 60vh;
            margin-bottom: 16px;
        }
        .case-block { border: 1px solid #eee; border-radius: 6px; margin-bottom: 16px; padding: 10px; }
        .case-title { font-weight: bold; margin-bottom: 6px; }
        .comb-block { border: 1px solid #f0f0f0; border-radius: 6px; margin-bottom: 8px; padding: 8px; position: relative; }
        .comb-params-row {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 8px;
            margin-bottom: 6px;
            position: relative;
        }
        .comb-param-select {
            flex: 0 1 auto;
            min-width: 180px;
            margin-bottom: 4px;
        }
        .select-default {
            background: #ffeaea !important;
            color: #b22222 !important;
        }
        .comb-remove-btn {
            font-size: 20px !important;
            font-weight: bold;
            padding: 0 6px;
            line-height: 1;
            background: none;
            border: none;
            color: #888;
            cursor: pointer;
            margin-left: auto;
            align-self: center;
            position: relative;
            z-index: 1;
        }
        .comb-remove-btn:hover { color: #d33; }
        .add-comb-btn { margin-bottom: 8px; }
        #tms-case-filter-apply { margin-top: 12px; }
        `;
        document.head.appendChild(style);

        // Overlay
        const overlay = document.createElement('div');
        overlay.id = 'tms-case-filter-overlay';

        // Модалка
        const modal = document.createElement('div');
        modal.id = 'tms-case-filter-modal';

        // Крестик для закрытия
        const closeBtn = document.createElement('button');
        closeBtn.id = 'tms-case-filter-close';
        closeBtn.innerHTML = '×';
        closeBtn.onclick = () => {
            overlay.remove();
            style.remove();
            onSave(caseCombinations); // Сохраняем при закрытии
        };
        modal.appendChild(closeBtn);

        // Закрытие по клику вне модалки
        overlay.addEventListener('mousedown', function(e) {
            if (!modal.contains(e.target)) {
                overlay.remove();
                style.remove();
                onSave(caseCombinations);
            }
        });

        // Контейнер для всех кейсов с прокруткой
        const allCasesDivScroll = document.createElement('div');
        allCasesDivScroll.id = 'tms-case-filter-cases-scroll';
        modal.appendChild(allCasesDivScroll);

        // Список уникальных кейсов
        const uniqueCases = Object.keys(caseParamValues);

        // Функция создания пустого сочетания
        function createEmptyCombination(caseName) {
            const comb = {};
            Object.keys(caseParamValues[caseName]).forEach(param => {
                comb[param] = '';
            });
            return comb;
        }

        // Рендер блоков для каждого кейса
        function renderAllCases() {
            allCasesDivScroll.innerHTML = '';
            uniqueCases.forEach(caseName => {
                const block = document.createElement('div');
                block.className = 'case-block';
                block.innerHTML = `<div class="case-title">${caseName}${caseTitles && caseTitles[caseName] ? ' — ' + caseTitles[caseName] : ''}</div>`;
                const combsContainer = document.createElement('div');
                // Рендер сочетаний
                (caseCombinations[caseName] || []).forEach((comb, idx) => {
                    const combBlock = document.createElement('div');
                    combBlock.className = 'comb-block';
                    const paramRow = document.createElement('div');
                    paramRow.className = 'comb-params-row';
                    Object.keys(caseParamValues[caseName]).forEach(param => {
                        const sel = document.createElement('select');
                        sel.className = 'comb-param-select';
                        sel.innerHTML = `<option value="">${param}</option>` +
                            caseParamValues[caseName][param].map(v => `<option value="${v}">${v}</option>`).join('');
                        sel.value = comb[param] || '';
                        // Подсветка дефолта
                        function updateSelectStyle() {
                            if (sel.value === '') sel.classList.add('select-default');
                            else sel.classList.remove('select-default');
                        }
                        sel.onchange = () => {
                            comb[param] = sel.value;
                            updateSelectStyle();
                        };
                        updateSelectStyle();
                        paramRow.appendChild(sel);
                    });
                    // Крестик всегда справа
                    const removeBtn = document.createElement('button');
                    removeBtn.className = 'comb-remove-btn';
                    removeBtn.innerHTML = '&times;';
                    removeBtn.title = 'Удалить сочетание';
                    removeBtn.onclick = () => {
                        caseCombinations[caseName].splice(idx, 1);
                        renderAllCases();
                    };
                    paramRow.appendChild(removeBtn);

                    combBlock.appendChild(paramRow);
                    combsContainer.appendChild(combBlock);
                });
                // Кнопка добавить сочетание
                const addCombBtn = document.createElement('button');
                addCombBtn.className = 'add-comb-btn';
                addCombBtn.textContent = 'Добавить сочетание';
                addCombBtn.onclick = function() {
                    caseCombinations[caseName].push(createEmptyCombination(caseName));
                    renderAllCases();
                };
                block.appendChild(combsContainer);
                block.appendChild(addCombBtn);
                allCasesDivScroll.appendChild(block);
            });
        }

        renderAllCases();

        // Кнопка применить
        const applyBtn = document.createElement('button');
        applyBtn.id = 'tms-case-filter-apply';
        applyBtn.textContent = 'Применить';
        applyBtn.onclick = function() {
            overlay.remove();
            style.remove();
            onSave(caseCombinations); // Сохраняем при применении
            // Для каждого кейса на странице ищем его name, сравниваем параметры с сочетаниями для этого name
            cases.forEach(c => {
                const combs = caseCombinations[c.name] || [];
                // Если сочетаний нет — кейс остаётся
                if (!combs.length) {
                    c.item.style.background = '';
                    if (c.checkbox.checked) c.checkbox.click();
                    return;
                }
                // Кейс подходит, если совпадает хотя бы с одним сочетанием
                const isMatch = combs.some(comb =>
                    Object.entries(comb).every(([k, v]) => !v || c.paramsObj[k] === v)
                );
                if (!isMatch && !c.checkbox.checked) c.checkbox.click();
                if (isMatch && c.checkbox.checked) c.checkbox.click();
                if (!isMatch) c.item.style.background = '#ffe0e0';
                else c.item.style.background = '';
            });
            // alert убран!
        };
        modal.appendChild(applyBtn);

        overlay.appendChild(modal);
        document.body.appendChild(overlay);
    }

    // --- Основная логика ---
    function main() {
        const cases = getCases();
        const caseParamValues = getCaseParamValues(cases);

        // Ключ для localStorage — уникальный для каждого рана
        const runKey = 'tms-case-filter-combs-' + location.pathname;

        // Загружаем сохранённые сочетания
        let saved = localStorage.getItem(runKey);
        let caseCombinations = {};
        if (saved) {
            try {
                caseCombinations = JSON.parse(saved);
            } catch (e) {}
        }
        // Инициализация для новых кейсов
        Object.keys(caseParamValues).forEach(name => {
            if (!caseCombinations[name]) caseCombinations[name] = [];
        });

        // Собираем названия кейсов
        const caseTitles = {};
        cases.forEach(c => { caseTitles[c.name] = c.title; });

        // Добавляем кнопку для открытия фильтра
        if (!document.getElementById('tms-case-filter-btn')) {
            const btn = document.createElement('button');
            btn.id = 'tms-case-filter-btn';
            btn.textContent = 'Фильтр кейсов';
            const dragState = makeDraggable(btn, 'tms-case-filter-btn-pos-' + location.pathname);

            btn.addEventListener('click', function(e) {
                if (!dragState.wasMoved()) {
                    showOverlay(cases, caseParamValues, caseCombinations, (newCombs) => {
                        caseCombinations = newCombs;
                        localStorage.setItem(runKey, JSON.stringify(caseCombinations));
                    }, caseTitles);
                }
                dragState.resetMoved();
            });

            document.body.appendChild(btn);
        }
    }

    // Ждём появления кейсов
    function waitForCases() {
        const interval = setInterval(() => {
            if (document.querySelectorAll('.run-case__item').length > 0) {
                clearInterval(interval);
                main();
            }
        }, 500);
        setTimeout(() => clearInterval(interval), 10000);
    }

    waitForCases();
})();