lqlLQL / Metabase数据可视化工具

// ==UserScript==
// @name         Metabase数据可视化工具
// @namespace    http://tampermonkey.net/
// @version      1.7.3
// @description  现代化嵌入式数据可视化工具
// @author       lqlLQL
// @match        *://ops.q1.com/metabase*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addValueChangeListener
// @require      https://d3js.org/d3.v6.min.js
// @connect      ops.q1.com
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    /* =================================================
       常量和全局变量定义
    =================================================== */
    const TARGET_PATTERN = /\/metabase\/api\/embed\/dashboard\/([^\/]+)\/dashcard\/(\d+)\/card\/(\d+)(?:\.json)?/;
    const GAME_MAPPING = [
        { gameid: "2124", gameversion: "2124-EA-ZS", label: "位面M数据" },
        { gameid: "2138", gameversion: "2138-SA-ZS", label: "终结者数据" },
        { gameid: "2172", gameversion: "2172-EA-ZS", label: "Sclash数据" }
    ];
    const STORAGE_PREFIX = "metabase_token_";
    let currentData = {
        rows: [],
        cols: [],
        processedItems: []
    };
    let CurrentTokenType = null;
    let allUrls = null;
    let checkInterval = null;
    let originalContentWrapper = null;

    /* =================================================
       样式配置
    =================================================== */
    const styles = {
        container: `
            width: 100%; height: 78vh;
            background: #ffffff;
            border-radius: 16px;
            box-shadow: 0 8px 32px rgba(0,0,0,0.08);
            padding: 24px;
            font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
            display: none;
            opacity: 0;
            transform: translateY(10px);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        `,
        nav: `
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 5px;
            border-bottom: 1px solid #f0f2f5;
        `,
        refreshBtn: `
            padding: 8px;
            background: #f8f9fa;
            border: 1px solid #e9ecef;
            border-radius: 8px;
            color: #4dabf7;
            cursor: pointer;
            transition: all 0.2s ease;
            display: flex;
            align-items: center;
        `,
        closeBtn: `
            background: #fff0f6;
            color: #f06595;
            width: 32px;
            height: 32px;
            border: none;
            border-radius: 50%;
            font-size: 20px;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.2s ease;
        `,
        toggleBtn: `
            position: fixed;
            bottom: 24px;
            right: 24px;
            padding: 12px 24px;
            background: #1990ff;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            z-index: 1000000;
            box-shadow: 0 4px 12px rgba(77, 171, 247, 0.3);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            font-weight: 500;
        `,
        tab: `
            padding: 12px 24px;
            border-radius: 8px;
            border: none;
            background: #f8f9fa;
            color: #868e96;
            cursor: pointer;
            transition: all 0.2s ease;
            font-weight: 500;
        `,
        tabActive: `
            background: #1990ff;
            color: white;
        `
    };

    const globalCSS = `
        .viz-tab:hover {
            background: #f1f3f5;
            color: #495057;
        }
        .viz-tab[active] {
            background: #4dabf7 !important;
            color: white !important;
            box-shadow: 0 2px 8px rgba(77, 171, 247, 0.3);
        }
        @keyframes spin {
            to { transform: rotate(360deg); }
        }
        @keyframes viz-spin {
            to { transform: rotate(360deg); }
        }
        @keyframes slideIn {
            from { opacity: 0; transform: translateX(-20px); }
            to { opacity: 1; transform: translateX(0); }
        }
        @keyframes bounce {
            0%, 100% { transform: translateY(-10px); }
            50% { transform: translateY(-15px); }
        }
        .custom-scrollbar {
            scrollbar-width: thin;
            scrollbar-color: #4dabf7 #f1f3f5;
        }
        /* 下拉多选框样式 */
        .type-selector-wrapper {
            position: relative;
            display: inline-block;
        }
        .type-selector-btn {
            padding: 6px 12px;
            background: #fff;
            border: 1px solid #4dabf7;
            border-radius: 6px;
            color: #4dabf7;
            cursor: pointer;
            font-size: 13px;
            display: flex;
            align-items: center;
            gap: 6px;
            white-space: nowrap;
        }
        .type-selector-btn:hover {
            background: #e8f4ff;
        }
        .type-selector-dropdown {
            position: absolute;
            top: calc(100% + 4px);
            left: 0;
            z-index: 9999;
            background: #fff;
            border: 1px solid #e9ecef;
            border-radius: 8px;
            box-shadow: 0 4px 16px rgba(0,0,0,0.12);
            padding: 8px 0;
            min-width: 160px;
            display: none;
        }
        .type-selector-dropdown.open {
            display: block;
        }
        .type-selector-item {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 8px 16px;
            cursor: pointer;
            font-size: 13px;
            color: #343a40;
            transition: background 0.15s;
        }
        .type-selector-item:hover {
            background: #f1f3f5;
        }
        .type-selector-item input[type="checkbox"] {
            transform: scale(1.1);
            cursor: pointer;
        }
        /* loading 转圈动画 */
        .viz-loading-spinner {
            width: 48px;
            height: 48px;
            border: 4px solid #e9ecef;
            border-top-color: #4dabf7;
            border-radius: 50%;
            animation: viz-spin 0.8s linear infinite;
        }
    `;
    injectGlobalStyle(globalCSS);

    /* =================================================
       DOM节点创建
    =================================================== */
    const container = createElement('div', { id: 'metabase-viz-container', style: styles.container });
    const nav = createElement('div', { style: styles.nav });
    const refreshBtn = createElement('button', { style: styles.refreshBtn, innerHTML: '<svg viewBox="0 0 24 24" width="20" height="20" style="fill:currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>' });
    const closeBtn = createElement('button', { style: styles.closeBtn, innerHTML: '&times;' });
    const tabContainer = createElement('div', { style: 'display: flex; gap: 8px; left: 5%; position: absolute;' });
    const contentArea = createElement('div', { style: 'position: relative; min-height: 400px; transition: opacity 0.3s ease;' });
    const toggleBtn = createElement('button', { style: styles.toggleBtn, textContent: '数据视图' });

    nav.append(refreshBtn, tabContainer, closeBtn);
    container.append(nav, contentArea);

    /* =================================================
       辅助函数
    =================================================== */
    function createElement(tag, props) {
        const isSVG = ['svg', 'g', 'path'].includes(tag);
        const element = isSVG
            ? document.createElementNS('http://www.w3.org/2000/svg', tag)
            : document.createElement(tag);

        if (props) {
            Object.entries(props).forEach(([key, value]) => {
                if (key === 'style' && typeof value === 'object') {
                    Object.assign(element.style, value);
                } else if (key === 'style' && typeof value === 'string') {
                    element.setAttribute('style', value);
                } else if (key === 'textContent') {
                    element.textContent = value;
                } else if (key === 'innerHTML') {
                    element.innerHTML = value;
                } else if (key === 'append') {
                    element.append(...value);
                } else if (isSVG && key === 'transform') {
                    element.setAttribute('transform', value);
                } else if (key in element) {
                    element[key] = value;
                } else {
                    element.setAttribute(key, value);
                }
            });
        }
        return element;
    }

    function injectGlobalStyle(css) {
        const styleTag = document.createElement('style');
        styleTag.textContent = css;
        document.head.appendChild(styleTag);
    }

    function createTab(label, active = false) {
        const tab = createElement('button', { style: styles.tab, textContent: label });
        tab.classList.add('viz-tab');
        if (active) tab.style.cssText += styles.tabActive;
        return tab;
    }

    function setActiveTab(activeTab) {
        document.querySelectorAll('.viz-tab').forEach(tab => {
            if (tab === activeTab) {
                tab.style.cssText += styles.tabActive;
            } else {
                tab.style.background = 'transparent';
                tab.style.color = '#666';
            }
        });
    }

    function showError(message) {
        contentArea.innerHTML = '';
        const errorBox = createElement('div', {
            textContent: message,
            style: `
                color: #dc3544; padding: 20px;
                border: 1px solid #f5c6cb;
                background: #f8d7da; border-radius: 5px;
                margin-top: 20px;
            `
        });
        contentArea.appendChild(errorBox);
    }

    /* =================================================
       【修复】加载等待UI
    =================================================== */
    function showLoadingUI() {
        tabContainer.innerHTML = '';
        contentArea.innerHTML = '';
        const loadingWrap = createElement('div', {
            style: `
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                height: 60vh;
                gap: 20px;
            `
        });

        const spinner = createElement('div', {});
        spinner.className = 'viz-loading-spinner';

        const loadingText = createElement('div', {
            textContent: '数据加载中,请稍候…',
            style: `
                color: #868e96;
                font-size: 15px;
                letter-spacing: 0.5px;
            `
        });

        loadingWrap.append(spinner, loadingText);
        contentArea.appendChild(loadingWrap);
    }

    /* =================================================
       核心数据处理函数
    =================================================== */
    function getDesignerCostEntries(item, { DataCalibra, Point120 }) {
        const {
            parsedCreative: primaryCreative,
            parsedDesigner: primaryDesigner,
            secondaryCreative,
            secondaryDesigner,
            originalCost,
            adjustedCost,
            difficulty
        } = item;

        if (!primaryDesigner || !originalCost) return [];
        const cost = DataCalibra ? adjustedCost : originalCost;

        if (!DataCalibra) {
            return [{ designer: primaryDesigner, cost }];
        }

        if (difficulty === 'W') {
            return [{ designer: primaryDesigner, cost }];
        }

        if (secondaryDesigner) {
            const primaryCost   = (Point120 && primaryCreative === primaryDesigner)     ? cost * 1.2 : cost;
            const secondaryCost = (Point120 && secondaryCreative === secondaryDesigner) ? cost * 1.2 : cost;
            return [
                { designer: primaryDesigner,   cost: primaryCost },
                { designer: secondaryDesigner, cost: secondaryCost }
            ];
        }

        const finalCost = (Point120 && primaryCreative === primaryDesigner) ? cost * 1.2 : cost;
        return [{ designer: primaryDesigner, cost: finalCost }];
    }

    /* =================================================
       获取缓存中已存储的所有 type 集合
    =================================================== */
    function getStoredTypes() {
        const allKeys = GM_listValues();
        const tokenKeys = allKeys.filter(key => key.startsWith(STORAGE_PREFIX));
        const types = new Set();
        tokenKeys.forEach(key => {
            const data = GM_getValue(key);
            if (data && data.type) {
                types.add(data.type);
            }
        });
        return types;
    }

    /* =================================================
       下拉多选框组件(两个图表复用)
    =================================================== */
    function createTypeSelector(onChange) {
        const allTypes = GAME_MAPPING.map(g => g.label);

        const wrapper = createElement('div', {});
        wrapper.className = 'type-selector-wrapper';

        const btn = createElement('button', {});
        btn.className = 'type-selector-btn';

        const dropdown = createElement('div', {});
        dropdown.className = 'type-selector-dropdown';

        const checkboxMap = {};

        allTypes.forEach(type => {
            const item = createElement('div', {});
            item.className = 'type-selector-item';

            const cb = createElement('input', {});
            cb.type = 'checkbox';
            cb.checked = CurrentTokenType ? (type === CurrentTokenType) : true;
            cb.dataset.type = type;
            checkboxMap[type] = cb;

            const lbl = document.createTextNode(type);

            //change 事件中增加缓存校验逻辑
            cb.addEventListener('change', () => {
                // 当用户勾选某个类型时,检查缓存中是否有对应 token
                if (cb.checked) {
                    const storedTypes = getStoredTypes();
                    if (!storedTypes.has(type)) {
                        // 弹出提示,告知用户需要先加载对应页面的数据缓存
                        alert(`请打开"${type}"页面先加载数据缓存,然后刷新`);
                        // 自动取消勾选,不触发后续渲染
                        cb.checked = false;
                        updateBtnLabel();
                        return;
                    }
                }
                updateBtnLabel();
                if (onChange) onChange();
            });

            item.addEventListener('click', (e) => {
                if (e.target !== cb) {
                    cb.checked = !cb.checked;
                    cb.dispatchEvent(new Event('change'));
                }
            });

            item.append(cb, lbl);
            dropdown.appendChild(item);
        });

        // ★ 新增:初始化时也校验,把没有缓存的类型默认取消勾选
        const storedTypesOnInit = getStoredTypes();
        allTypes.forEach(type => {
            if (checkboxMap[type] && checkboxMap[type].checked && !storedTypesOnInit.has(type)) {
                checkboxMap[type].checked = false;
            }
        });

        function updateBtnLabel() {
            const selected = getSelectedTypes();
            if (selected.length === 0) {
                btn.textContent = '数据类型 ▾';
            } else if (selected.length === allTypes.length) {
                btn.textContent = '全部类型 ▾';
            } else {
                btn.textContent = selected.join(' + ') + ' ▾';
            }
        }

        function getSelectedTypes() {
            return allTypes.filter(t => checkboxMap[t] && checkboxMap[t].checked);
        }

        btn.addEventListener('click', (e) => {
            e.stopPropagation();
            dropdown.classList.toggle('open');
        });

        document.addEventListener('click', () => {
            dropdown.classList.remove('open');
        });

        updateBtnLabel();
        wrapper.append(btn, dropdown);

        return { el: wrapper, getSelectedTypes };
    }

    /* =================================================
       UI渲染模块
    =================================================== */
    function renderUI() {
        tabContainer.innerHTML = '';
        contentArea.innerHTML = '';

        const tableTab      = createTab('数据表格', true);
        const BarchartTab   = createTab('设计师消耗图');
        const LinechartTab  = createTab('按日消耗图');
        tabContainer.append(tableTab, BarchartTab, LinechartTab);

        const tableContent    = createTable(currentData.rows, currentData.cols, 20);
        const BarchartContent = createBarChart(currentData);
        const LinechartPkg    = createLineChart(currentData);

        tableTab.onclick = () => {
            setActiveTab(tableTab);
            contentArea.innerHTML = '';
            contentArea.appendChild(tableContent);
        };

        BarchartTab.onclick = () => {
            setActiveTab(BarchartTab);
            contentArea.innerHTML = '';
            contentArea.appendChild(BarchartContent);
        };

        LinechartTab.onclick = () => {
            setActiveTab(LinechartTab);
            contentArea.innerHTML = '';
            contentArea.appendChild(LinechartPkg.el);
            requestAnimationFrame(() => {
                LinechartPkg.render();
            });
        };

        setActiveTab(tableTab);
        contentArea.appendChild(tableContent);
    }

    // 表格生成函数
    function createTable(rows, cols, pageSize) {
        const wrapper = createElement('div', {
            style: `
                max-height: 60vh;
                overflow-y: auto;
                border-radius: 12px;
                position: relative;
            `
        });
        wrapper.classList.add('custom-scrollbar');

        const tableContainer = document.createElement('div');

        let currentPage = 1;
        const totalPages = Math.ceil(rows.length / pageSize);

        function renderTablePage(page) {
            tableContainer.innerHTML = '';

            const table = createElement('table', {
                style: `
                    width: 100%;
                    border-collapse: separate;
                    border-spacing: 0;
                    margin-top: 16px;
                    font-size: 14px;
                `
            });

            const thead = document.createElement('thead');
            const headerRow = document.createElement('tr');
            cols.forEach(col => {
                const th = createElement('th', {
                    textContent: col.display_name,
                    style: `
                        padding: 16px;
                        background: #f8f9fa;
                        color: #495057;
                        font-weight: 600;
                        position: sticky;
                        top: 0;
                        backdrop-filter: blur(4px);
                    `
                });
                headerRow.appendChild(th);
            });
            thead.appendChild(headerRow);

            const tbody = document.createElement('tbody');
            const startIndex = (page - 1) * pageSize;
            const endIndex = Math.min(startIndex + pageSize, rows.length);

            for (let rowIndex = startIndex; rowIndex < endIndex; rowIndex++) {
                const row = rows[rowIndex];
                const tr = document.createElement('tr');
                tr.style.cssText = `
                    transition: background 0.2s ease;
                    background: ${rowIndex % 2 === 0 ? '#fff' : '#f8f9fa'};
                `;
                row.forEach((cell, index) => {
                    if ([11, 12, 13, 14, 15, 16, 17, 18, 19].includes(index)) {
                        cell = (cell * 100).toFixed(1) + "%";
                    }
                    const td = createElement('td', {
                        textContent: (cell !== null && cell !== undefined) ? cell : '-',
                        style: `
                            padding: 14px;
                            border-bottom: 1px solid #f1f3f5;
                            white-space: nowrap;
                            overflow: hidden;
                            text-overflow: ellipsis;
                        `
                    });
                    if ([8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20].includes(index)) {
                        td.style.color = '#4dabf7';
                        td.style.fontWeight = '500';
                    }
                    tr.appendChild(td);
                });
                tr.onmouseenter = () => { tr.style.background = '#f1f3f5'; };
                tr.onmouseleave = () => { tr.style.background = rowIndex % 2 === 0 ? '#fff' : '#f8f9fa'; };
                tbody.appendChild(tr);
            }

            table.appendChild(thead);
            table.appendChild(tbody);
            tableContainer.appendChild(table);
        }

        const paginationControls = document.createElement('div');
        paginationControls.style.cssText = `margin-top: 10px; text-align: center;`;

        function updatePaginationControls() {
            paginationControls.innerHTML = '';

            const prevButton = document.createElement('button');
            prevButton.textContent = '上一页';
            prevButton.disabled = currentPage === 1;
            prevButton.onclick = () => {
                if (currentPage > 1) {
                    currentPage--;
                    renderTablePage(currentPage);
                    updatePaginationControls();
                }
            };
            paginationControls.appendChild(prevButton);

            const pageInfo = document.createElement('span');
            pageInfo.textContent = ` ${currentPage} / ${totalPages} `;
            pageInfo.style.margin = '0 10px';
            paginationControls.appendChild(pageInfo);

            const nextButton = document.createElement('button');
            nextButton.textContent = '下一页';
            nextButton.disabled = currentPage === totalPages;
            nextButton.onclick = () => {
                if (currentPage < totalPages) {
                    currentPage++;
                    renderTablePage(currentPage);
                    updatePaginationControls();
                }
            };
            paginationControls.appendChild(nextButton);
        }

        renderTablePage(currentPage);
        updatePaginationControls();

        wrapper.appendChild(tableContainer);
        wrapper.appendChild(paginationControls);
        return wrapper;
    }

    // 柱状图生成函数
    function createBarChart(currentData) {
        const chartContainer = createElement('div', {
            style: `
                margin-top: 24px;
                padding: 24px;
                background: #f8f9fa;
                border-radius: 12px;
                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
                position: relative;
            `
        });

        const controlPanel = createElement('div', {
            style: `
                display: flex;
                gap: 16px;
                align-items: center;
                flex-wrap: wrap;
                padding: 16px;
                border-bottom: 2px solid #e9ecef;
                margin-bottom: 24px;
            `
        });

        const createCheckbox = (labelText, defaultChecked) => {
            const checkbox = createElement('input', {});
            checkbox.type = 'checkbox';
            checkbox.checked = defaultChecked;
            checkbox.style.cssText = 'margin-right: 8px; transform: scale(1.2); vertical-align: middle;';
            const label = createElement('label', {
                textContent: labelText,
                style: 'display: flex; align-items: center;'
            });
            label.prepend(checkbox);
            return { checkbox, label };
        };

        const typeSelector = createTypeSelector(() => renderChart());

        const { checkbox: excludeCheck,     label: excludeLabel }      = createCheckbox('排除"other"的数据', true);
        const { checkbox: showAllCheck,     label: showAllLabel }       = createCheckbox('显示所有设计师', false);
        const { checkbox: DataCalibraCheck, label: DataCalibraLabel }   = createCheckbox('数据校准', true);
        const { checkbox: Point120Check,    label: Point120Label }      = createCheckbox('+20%', false);

        const barRefreshBtn = createElement('button', {
            textContent: '刷新图表',
            style: `
                padding: 8px 16px;
                background: #4dabf7;
                color: white;
                border: none;
                border-radius: 6px;
                cursor: pointer;
                margin-left: auto;
            `
        });

        controlPanel.append(
            typeSelector.el,
            excludeLabel,
            showAllLabel,
            DataCalibraLabel,
            Point120Label,
            barRefreshBtn
        );
        chartContainer.appendChild(controlPanel);

        const allowedDesigners = ['LQL', 'ZMD', 'XXQ', 'LYQ', 'XFY', 'WZY', 'CZM', 'WZG', 'PLH'];
        let previousData = {};

        const renderChart = () => {
            const selectedTypes    = typeSelector.getSelectedTypes();
            const excludeOther     = excludeCheck.checked;
            const showAllDesigners = showAllCheck.checked;
            const DataCalibra      = DataCalibraCheck.checked;
            const Point120         = Point120Check.checked;

            const newData = {};

            currentData.processedItems.forEach(item => {
                const {
                    parsedCreative: primaryCreative,
                    parsedDesigner: primaryDesigner,
                    secondaryDesigner,
                    _type: type
                } = item;

                if (selectedTypes.length > 0 && !selectedTypes.includes(type)) return;
                if (excludeOther && primaryCreative === 'other') return;

                const inList = allowedDesigners.includes(primaryDesigner) || allowedDesigners.includes(secondaryDesigner);
                if (!showAllDesigners && !inList) return;

                const entries = getDesignerCostEntries(item, { DataCalibra, Point120 });

                entries.forEach(({ designer, cost }) => {
                    if (showAllDesigners || allowedDesigners.includes(designer)) {
                        newData[designer] = (newData[designer] || 0) + cost;
                    }
                });
            });

            const designerStats = {};
            Object.keys(newData).forEach(designer => {
                const oldValue = previousData[designer] || 0;
                const newValue = newData[designer];
                designerStats[designer] = {
                    total: newValue.toFixed(1),
                    originalTotal: newValue,
                    diff: newValue - oldValue,
                    oldValue
                };
            });

            const sortedStats = Object.entries(designerStats)
                .sort((a, b) => b[1].originalTotal - a[1].originalTotal);

            const maxValue = Math.max(...sortedStats.map(s => s[1].originalTotal)) || 1;

            while (chartContainer.lastChild !== controlPanel) {
                chartContainer.removeChild(chartContainer.lastChild);
            }

            let R_index = 0;
            sortedStats.forEach(([name, data], index) => {
                const barWrapper = createElement('div', {
                    style: `
                        display: flex;
                        align-items: center;
                        margin: 16px 0;
                        position: relative;
                    `
                });

                if (data.oldValue === 0) {
                    R_index += 1;
                    barWrapper.style.opacity = '0';
                    barWrapper.style.transform = 'translateX(-20px)';
                    barWrapper.style.animation = `slideIn 0.4s ease ${R_index * 0.1}s forwards`;
                }

                const label = createElement('div', {
                    textContent: name,
                    style: `
                        width: 50px;
                        font-weight: 500;
                        color: #343a40;
                        overflow: hidden;
                        text-overflow: ellipsis;
                    `
                });

                const barContainer = createElement('div', {
                    style: `
                        flex: 1;
                        height: 12px;
                        background: #e9ecef;
                        border-radius: 6px;
                        margin: 0 16px;
                        overflow: hidden;
                        position: relative;
                    `
                });

                const percentage = (data.originalTotal / maxValue) * 100;

                const diffIndicator = createElement('div', {
                    textContent: `${name}: ${data.diff > 0 ? '+' : ''}${data.diff.toFixed(1)}`,
                    style: `
                        position: absolute;
                        top: -20px;
                        left: 50%;
                        transform: translateX(-50%);
                        background: ${data.diff < 0 ? '#F44336' : '#4CAF50'};
                        color: white;
                        padding: 2px 6px;
                        border-radius: 4px;
                        font-size: 0.8em;
                        opacity: 0;
                        transition: opacity 0.3s;
                        white-space: nowrap;
                    `
                });

                const gradient = createElement('div', {
                    style: `
                        width: ${data.oldValue / maxValue * 100}%;
                        height: 100%;
                        background: linear-gradient(90deg, #69db7c, #4dabf7);
                        border-radius: 6px;
                        transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1) ${index * 0.07}s;
                    `
                });

                barWrapper.addEventListener('mouseenter', () => {
                    diffIndicator.style.opacity = 1;
                    barContainer.style.transform = 'scaleY(1.2)';
                    barContainer.style.zIndex = 2;
                });
                barWrapper.addEventListener('mouseleave', () => {
                    diffIndicator.style.opacity = 0;
                    barContainer.style.transform = 'scaleY(1)';
                    barContainer.style.zIndex = 1;
                });

                setTimeout(() => {
                    requestAnimationFrame(() => {
                        gradient.style.width = `${percentage}%`;
                    });
                }, 0);

                const value = createElement('div', {
                    textContent: data.total,
                    style: `
                        width: 80px;
                        text-align: right;
                        font-weight: 600;
                        color: #4dabf7;
                        font-feature-settings: 'tnum';
                    `
                });

                barContainer.appendChild(gradient);
                barWrapper.append(label, barContainer, value, diffIndicator);
                chartContainer.appendChild(barWrapper);
            });

            previousData = { ...newData };
        };

        renderChart();

        [excludeCheck, showAllCheck, DataCalibraCheck, Point120Check].forEach(cb => {
            cb.addEventListener('change', () => renderChart());
        });

        barRefreshBtn.addEventListener('click', () => {
            previousData = {};
            renderChart();
        });

        return chartContainer;
    }

    /* =================================================
       折线图生成函数
    =================================================== */
    function createLineChart(currentData) {
        const chartContainer = createElement('div', {
            style: `
                margin: 24px;
                padding: 24px;
                background: #f8f9fa;
                border-radius: 12px;
                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            `
        });

        const controlPanel = createElement('div', {
            style: `
                display: flex;
                justify-content: flex-start;
                align-items: center;
                padding: 16px;
                border-bottom: 2px solid #e9ecef;
                margin-bottom: 24px;
                flex-wrap: wrap;
                gap: 16px;
            `
        });

        const createCheckbox = (labelText, checked) => {
            const checkbox = createElement('input', {});
            checkbox.type = 'checkbox';
            checkbox.checked = checked;
            checkbox.style.cssText = 'transform: scale(1.2); margin-right: 8px;';
            const label = createElement('label', {
                textContent: labelText,
                style: 'display: flex; align-items: center;'
            });
            label.prepend(checkbox);
            return { checkbox, label };
        };

        const typeSelector = createTypeSelector(() => debouncedRender());

        const { checkbox: excludeCheck,     label: excludeLabel }     = createCheckbox('排除"other"的数据', true);
        const { checkbox: cumulativeCheck,  label: cumulativeLabel }  = createCheckbox('累计消耗模式', false);
        const { checkbox: DataCalibraCheck, label: DataCalibraLabel } = createCheckbox('数据校准', true);
        const { checkbox: Point120Check,    label: Point120Label }    = createCheckbox('+20%', false);

        const lineRefreshBtn = createElement('button', {
            textContent: '刷新图表',
            style: `
                padding: 8px 16px;
                background: #4dabf7;
                color: white;
                border: none;
                border-radius: 6px;
                cursor: pointer;
                transition: all 0.2s;
                margin-left: auto;
            `
        });

        controlPanel.append(
            typeSelector.el,
            excludeLabel,
            cumulativeLabel,
            DataCalibraLabel,
            Point120Label,
            lineRefreshBtn
        );
        chartContainer.appendChild(controlPanel);

        const lineHint = createElement('div', {
            style: `
                display: none;
                margin: 0 16px 12px 16px;
                padding: 10px 12px;
                border-radius: 8px;
                font-size: 13px;
                line-height: 1.6;
            `
        });
        chartContainer.appendChild(lineHint);

        const svg = d3.select(chartContainer)
            .append('svg')
            .attr('width', '100%')
            .attr('height', 400)
            .style('overflow', 'visible');

        const legend = createElement('div', {
            style: `
                display: flex;
                flex-wrap: wrap;
                gap: 16px;
                padding: 16px;
                background: white;
                border-radius: 8px;
                margin-top: 16px;
                box-shadow: 0 2px 4px rgba(0,0,0,0.05);
            `
        });
        chartContainer.appendChild(legend);

        const designerConfig = {
            allowed: ['LQL', 'ZMD', 'XXQ', 'LYQ', 'XFY', 'WZY', 'CZM', 'WZG', 'PLH'],
            colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#FF8C42', '#7C5AC2', '#5D768C', '#E05D44']
        };

        function setLineHint(message, type = 'warn') {
            const isError = type === 'error';
            lineHint.style.display = 'block';
            lineHint.style.background = isError ? '#fff5f5' : '#fff9db';
            lineHint.style.border = `1px solid ${isError ? '#ffa8a8' : '#ffe066'}`;
            lineHint.style.color = isError ? '#c92a2a' : '#8a6d3b';
            lineHint.textContent = `⚠ ${message}`;
        }

        function clearLineHint() {
            lineHint.style.display = 'none';
            lineHint.textContent = '';
        }

        function parseConsumeDate(raw) {
            if (raw === null || raw === undefined) return null;
            const str = String(raw).trim();
            if (!str || str === '/' || str.toLowerCase() === 'null' || str.toLowerCase() === 'undefined') return null;
            if (str.includes('~')) return null;

            const m = str.match(/^(\d{4})[-\/](\d{2})[-\/](\d{2})$/);
            if (!m) return null;

            const y  = Number(m[1]);
            const mo = Number(m[2]);
            const d  = Number(m[3]);

            const dt = new Date(y, mo - 1, d);
            if (
                dt.getFullYear() !== y ||
                dt.getMonth() !== mo - 1 ||
                dt.getDate() !== d
            ) {
                return null;
            }

            return `${y}/${String(mo).padStart(2, '0')}/${String(d).padStart(2, '0')}`;
        }

        function renderChart() {
            svg.selectAll('*').remove();
            legend.innerHTML = '';
            clearLineHint();

            const selectedTypes = typeSelector.getSelectedTypes();
            const excludeOther  = excludeCheck.checked;
            const isCumulative  = cumulativeCheck.checked;
            const DataCalibra   = DataCalibraCheck.checked;
            const Point120      = Point120Check.checked;

            const dataMap  = {};
            const dateSet  = new Set();
            const colorMap = {};
            designerConfig.allowed.forEach((d, i) => colorMap[d] = designerConfig.colors[i]);

            let candidateCount = 0;
            let invalidDateCount = 0;
            let rangeDateCount = 0;

            currentData.processedItems.forEach(item => {
                const {
                    消耗日期: rdate,
                    parsedCreative: primaryCreative,
                    parsedDesigner: primaryDesigner,
                    secondaryDesigner,
                    originalCost,
                    _type: type
                } = item;

                if (!primaryDesigner || !originalCost) return;
                if (selectedTypes.length > 0 && !selectedTypes.includes(type)) return;
                if (excludeOther && primaryCreative === 'other') return;

                const inList = designerConfig.allowed.includes(primaryDesigner) || designerConfig.allowed.includes(secondaryDesigner);
                if (!inList) return;

                candidateCount++;

                const date = parseConsumeDate(rdate);
                if (!date) {
                    invalidDateCount++;
                    if (String(rdate || '').includes('~')) rangeDateCount++;
                    return;
                }

                dateSet.add(date);

                const entries = getDesignerCostEntries(item, { DataCalibra, Point120 });
                entries.forEach(({ designer, cost }) => {
                    if (!designerConfig.allowed.includes(designer)) return;
                    dataMap[designer] = dataMap[designer] || {};
                    dataMap[designer][date] = (dataMap[designer][date] || 0) + cost;
                });
            });

            if (candidateCount === 0) {
                setLineHint('当前筛选条件下暂无可绘制数据。', 'warn');
                return;
            }

            const dates = Array.from(dateSet).sort((a, b) => new Date(a) - new Date(b));

            if (dates.length === 0) {
                setLineHint('消耗日期解析失败,请在"海外广告素材报表"打开:维度 -> 消耗日期 -> 添加筛选器,然后重新点击"查询"。', 'error');
                return;
            }

            if (invalidDateCount > 0) {
                const extra = rangeDateCount > 0
                    ? `(其中 ${rangeDateCount} 条是"2026-MM-DD~2026-MM-DD"这类区间格式)`
                    : '';
                setLineHint(`有 ${invalidDateCount} 条记录的"消耗日期"不是单日格式 YYYY-MM-DD${extra},请在"海外广告素材报表"打开:维度 -> 消耗日期 -> 添加筛选器,然后重新点击"查询"。`, 'warn');
            }

            const series = designerConfig.allowed
                .filter(d => dataMap[d])
                .map(designer => {
                    const dataPoints = dates.map(date => ({
                        date,
                        value: dataMap[designer][date] || 0
                    }));
                    if (isCumulative) {
                        let sum = 0;
                        return {
                            name: designer,
                            color: colorMap[designer],
                            data: dataPoints.map(dp => ({ date: dp.date, value: (sum += dp.value) }))
                        };
                    }
                    return { name: designer, color: colorMap[designer], data: dataPoints };
                });

            if (series.length === 0) {
                setLineHint('当前筛选下没有设计师曲线可展示。', 'warn');
                return;
            }

            const timeExtent = d3.extent(dates, d => new Date(d));
            const tickCount  = Math.min(dates.length, 12);

            const margin = { top: 40, right: 60, bottom: 60, left: 60 };
            const svgNode = svg.node();
            const svgWidth = svgNode.clientWidth || svgNode.getBoundingClientRect().width || 600;
            const width  = svgWidth - margin.left - margin.right;
            const height = 400 - margin.top - margin.bottom;

            const xScale = d3.scaleTime().domain(timeExtent).range([0, width]);

            const yMax = d3.max(series, s => d3.max(s.data, d => d.value)) || 0;
            const yScale = d3.scaleLinear().domain([0, yMax > 0 ? yMax * 1.2 : 1]).range([height, 0]);

            svg.append('g')
                .attr('transform', `translate(${margin.left}, ${height + margin.top})`)
                .call(d3.axisBottom(xScale).ticks(tickCount).tickFormat(d3.timeFormat('%m-%d')));

            svg.append('g')
                .attr('transform', `translate(${margin.left}, ${margin.top})`)
                .call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format('.0f')));

            const line = d3.line()
                .x(d => xScale(new Date(d.date)) + margin.left)
                .y(d => yScale(d.value) + margin.top)
                .curve(d3.curveMonotoneX);

            series.forEach(s => {
                const path = svg.append('path')
                    .datum(s.data)
                    .attr('fill', 'none')
                    .attr('stroke', s.color)
                    .attr('stroke-width', 2.5)
                    .attr('opacity', 0.7)
                    .attr('d', line);

                const legendItem = createElement('div', {
                    style: `
                        display: flex;
                        align-items: center;
                        gap: 8px;
                        cursor: pointer;
                        padding: 8px;
                        border-radius: 4px;
                    `
                });

                const colorBox = createElement('div', {
                    style: `
                        width: 16px;
                        height: 16px;
                        background: ${s.color};
                        border-radius: 3px;
                    `
                });

                legendItem.append(colorBox, document.createTextNode(s.name));
                legend.appendChild(legendItem);

                const highlight = () => {
                    path.attr('stroke-width', 4).attr('opacity', 1);
                    legendItem.style.fontWeight = 'bold';
                };
                const unhighlight = () => {
                    path.attr('stroke-width', 2.5).attr('opacity', 0.7);
                    legendItem.style.fontWeight = 'normal';
                };

                path.on('mouseenter', highlight).on('mouseleave', unhighlight);
                legendItem.addEventListener('mouseenter', highlight);
                legendItem.addEventListener('mouseleave', unhighlight);
            });
        }

        const debounce = (func, delay) => {
            let timer;
            return (...args) => {
                clearTimeout(timer);
                timer = setTimeout(() => func.apply(this, args), delay);
            };
        };

        const debouncedRender = debounce(renderChart, 300);

        [excludeCheck, cumulativeCheck, DataCalibraCheck, Point120Check].forEach(cb => {
            cb.addEventListener('change', () => debouncedRender());
        });

        lineRefreshBtn.addEventListener('click', () => {
            svg.selectAll('*').remove();
            renderChart();
        });

        return { el: chartContainer, render: renderChart };
    }

    /* =================================================
       数据请求模块
    =================================================== */
    function setupXHRInterceptor() {
        const origOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function (method, url) {
            const match = url.match(TARGET_PATTERN);
            if (match) {
                storeTokenWithType(match[1]);
                CurrentTokenType = TokenType_GAME_MAPPING(match[1]).label;
                console.log(CurrentTokenType);
                allUrls = getAllTypeUrls(url);
                XMLHttpRequest.prototype.open = origOpen;
                logAllStoredTokens();
            }
            origOpen.apply(this, arguments);
        };
    }

    function base64UrlDecode(encoded) {
        try {
            return atob(encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(encoded.length + (4 - encoded.length % 4) % 4, '='));
        } catch { return null; }
    }

    function TokenType_GAME_MAPPING(token) {
        const [_, payloadSegment] = token.split('.');
        const decodedPayload = JSON.parse(base64UrlDecode(payloadSegment));
        const matchedGame = GAME_MAPPING.find(cfg =>
            cfg.gameid === decodedPayload.params?.gameid &&
            cfg.gameversion === decodedPayload.params?.gameversion
        );
        return matchedGame;
    }

    function storeTokenWithType(token) {
        try {
            const matchedGame = TokenType_GAME_MAPPING(token);
            if (matchedGame) {
                GM_setValue(`${STORAGE_PREFIX}${token}`, {
                    type: matchedGame.label,
                    token: token,
                    storedAt: Date.now()
                });
            }
        } catch (e) {
            console.error("存储失败:", e);
        }
    }

    function clearStorage({ type, keepDays } = {}) {
        const now = Date.now();
        const allKeys = GM_listValues().filter(k => k.startsWith('metabase_token_'));
        allKeys.forEach(key => {
            const data = GM_getValue(key);
            if (type && data.type !== type) return;
            if (keepDays && (now - data.storedAt) <= keepDays * 86400000) return;
            GM_deleteValue(key);
            console.log(`已清除:[${data.type}] ${key}`);
        });
    }

    function getAllTypeUrls(originalUrl) {
        const match = originalUrl.match(TARGET_PATTERN);
        if (!match) return [];

        const [, currentToken, dashcard, card] = match;
        const queryParams = originalUrl.split('?')[1] || '';

        const tokens = GM_listValues()
            .filter(k => k.startsWith(STORAGE_PREFIX))
            .map(k => GM_getValue(k));

        const latest = {};
        tokens.forEach(t => {
            if (!latest[t.type] || t.storedAt > latest[t.type].storedAt) {
                latest[t.type] = t;
            }
        });

        return Object.values(latest).map(t => ({
            type: t.type,
            url: `/metabase/api/embed/dashboard/${t.token}/dashcard/${dashcard}/card/${card}/json` +
                (queryParams ? `?${queryParams}` : '')
        }));
    }

    function logAllStoredTokens() {
        const allKeys = GM_listValues();
        const tokenKeys = allKeys.filter(key => key.startsWith(STORAGE_PREFIX));
        const tokens = tokenKeys.map(key => {
            const data = GM_getValue(key);
            return {
                key: key,
                type: data.type,
                token: data.token,
                storedAt: new Date(data.storedAt).toLocaleString()
            };
        });
        console.table(tokens, ["key", "type", "token", "storedAt"]);
    }

    function fetchData(urlList) {
        showLoadingUI();

        const promises = urlList.map(({ url, type }) => {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    responseType: "blob",
                    onload: (response) => {
                        const reader = new FileReader();
                        reader.onload = () => {
                            try {
                                const text = reader.result;
                                const rawData = JSON.parse(text);
                                const processedData = processRawData(rawData, type);
                                resolve(processedData);
                            } catch (e) {
                                reject(`处理失败 (${type}): ${e}`);
                            }
                        };
                        reader.onerror = () => reject(`文件读取失败 (${type})`);
                        reader.readAsText(response.response);
                    },
                    onerror: (err) => reject(`请求失败 (${type}): ${err}`)
                });
            });
        });

        return Promise.all(promises).then(allResults => {
            const aggregatedData = { rows: [], cols: [], processedItems: [] };

            allResults.forEach(result => {
                aggregatedData.rows.push(...result.rows);
                if (result.cols.length > 0 && aggregatedData.cols.length === 0) {
                    aggregatedData.cols = result.cols;
                }
                aggregatedData.processedItems.push(...result.processedItems);
            });

            currentData = aggregatedData;
            console.log("fetchData 完成:", currentData);
            refreshBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" style="fill:currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>';
            renderUI();
            return aggregatedData;
        });
    }

    function processRawData(rawData, type) {
        const cols = [
            "消耗日期", "素材名称", "首投日期", "素材前缀", "创意人", "设计师", "产出日期", "媒体", "消耗",
            "展示", "点击", "点击率", "首日ROI", "7日ROI", "30日ROI", "60日ROI", "90日ROI", "180日ROI", "360日ROI",
            "激活转化率", "cpm", "次日留存率", "系列名"
        ];

        const rows = rawData.map(item =>
            cols.map(col => {
                let value = item[col];
                if (typeof value === "string" && value.endsWith("%")) {
                    value = parseFloat(value.replace("%", "")) / 100;
                }
                if (value === "/") value = null;
                return value;
            })
        );

        const processedItems = rawData.map(item => {
            const processedItem = {};
            for (const key in item) {
                let value = item[key];
                if (typeof value === "string" && value.endsWith("%")) {
                    value = parseFloat(value.replace("%", "")) / 100;
                }
                if (value === "/") value = null;
                processedItem[key] = value;
            }

            const { primaryCreative, primaryDesigner, secondaryCreative, secondaryDesigner, difficulty } =
                parseMaterialName(processedItem["素材名称"]);

            const originalCost = parseFloat(
                String(processedItem["消耗"] || '').replace(/,/g, '')
            ) || 0;
            const hasSecondary = !!secondaryDesigner;
            const adjustedCost = calculateAdjustedCost(originalCost, difficulty, hasSecondary);

            return {
                ...processedItem,
                _type: type,
                parsedCreative: primaryCreative,
                parsedDesigner: primaryDesigner,
                secondaryCreative: secondaryCreative,
                secondaryDesigner: secondaryDesigner,
                adjustedCost: adjustedCost,
                originalCost: originalCost,
                difficulty: difficulty
            };
        });

        return {
            rows,
            cols: cols.map(name => ({ display_name: name })),
            processedItems
        };
    }

    function startMonitoring() {
        setupXHRInterceptor();
        clearInterval(checkInterval);
        checkInterval = setInterval(() => {
            if (allUrls) {
                clearInterval(checkInterval);
                fetchData(allUrls).then(() => { }).catch(err => {
                    console.warn('[Viz] 数据获取出错(可能是页面刷新导致):', err);
                });
            }
        }, 500);
    }

    function parseMaterialName(filename) {
        if (!filename) return { primaryDesigner: null, secondaryDesigner: null, difficulty: '' };
        //更新素材名称匹配空格(错误命名导致)
        const SEP = '(?:\\s*-\\s*|\\s+)';
        const regex = new RegExp(
            `^.*?([PRSVH])${SEP}([^-\\s]+)${SEP}([^-\\s]+)${SEP}\\d{6}` +
            `(?:\\+([^\\+]+))?(?:\\+([^\\+]+))?([ABCDEW]?)\\.?.*$`,
            'i'
        );
        
        const matches = filename.match(regex);
        return {
            primaryCreative:   matches?.[2] || null,
            primaryDesigner:   matches?.[3] || null,
            secondaryCreative: matches?.[4] || null,
            secondaryDesigner: matches?.[5] || null,
            difficulty:        (matches?.[6] || '').toUpperCase()
        };
    }

    function calculateAdjustedCost(originalCost, difficulty, hasSecondary) {
        let adjustment = 1;
        if (difficulty === 'W') adjustment *= 0.2;
        if (hasSecondary) adjustment *= 0.5;
        return originalCost * adjustment;
    }

    function reloadData() {
        if (allUrls) {
            refreshBtn.innerHTML = '加载中...';
            fetchData(allUrls).then(() => { }).catch(err => {
                console.warn('[Viz] 刷新数据失败:', err);
                refreshBtn.innerHTML = '加载失败,点击重试';
            });
        }
    }

    /* =================================================
       事件绑定模块
    =================================================== */
    refreshBtn.onmouseenter = () => {
        refreshBtn.style.transform = 'rotate(90deg) scale(1.1)';
        refreshBtn.style.boxShadow = '0 2px 8px rgba(77, 171, 247, 0.2)';
    };
    refreshBtn.onmouseleave = () => {
        refreshBtn.style.transform = 'none';
        refreshBtn.style.boxShadow = 'none';
    };
    refreshBtn.onclick = reloadData;

    closeBtn.onclick = () => {
        container.style.opacity = '0';
        container.style.transform = 'translateY(10px)';
        setTimeout(() => {
            container.style.display = 'none';
            originalContentWrapper.style.display = '';
        }, 300);
        toggleBtn.textContent = '数据视图';
    };

    toggleBtn.onclick = function () {
        if (container.style.display === 'none' || container.style.display === '') {
            originalContentWrapper.style.display = 'none';
            container.style.display = 'block';
            setTimeout(() => {
                container.style.opacity = '1';
                container.style.transform = 'translateY(0)';
            }, 10);
            toggleBtn.textContent = '原始视图';

            if (allUrls && !currentData.rows.length) {
                fetchData(allUrls).then(() => { }).catch(err => {
                    console.warn('[Viz] 切换视图时加载失败:', err);
                });
            }
        } else {
            container.style.opacity = '0';
            container.style.transform = 'translateY(10px)';
            setTimeout(() => {
                container.style.display = 'none';
                originalContentWrapper.style.display = '';
            }, 300);
            toggleBtn.textContent = '数据视图';
        }
    };

    (function bindMetabaseRefresh() {
        const mbRefresh = document.querySelector('.QB3Fo');
        if (mbRefresh) {
            mbRefresh.addEventListener('click', () => {
                allUrls = null;
                startMonitoring();
                closeBtn.onclick();
            });
        } else {
            setTimeout(bindMetabaseRefresh, 500);
        }
    })();

    /* =================================================
       初始化
    =================================================== */
    function initializeContainer() {
        const targetContainer = document.querySelectorAll('.emotion-cef3n5')[1];
        if (!targetContainer) {
            console.warn('[Viz] 未找到容器,500ms后重试...');
            setTimeout(initializeContainer, 500);
            return;
        }
        console.log('[Viz] ✅ 容器挂载成功');
        originalContentWrapper = targetContainer;
        targetContainer.parentElement.insertBefore(container, targetContainer.nextSibling);
        document.body.appendChild(toggleBtn);
        clearStorage({ keepDays: 1 });
    }

    /* =================================================
       脚本启动入口
    =================================================== */
    startMonitoring();
    initializeContainer();
})();