NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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: '×' });
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();
})();