// ==UserScript== // @name 火光小岛数据看板 Pro // @namespace https://tampermonkey.net/ // @version 3.2.0 // @description 支持个人后台/总后台,渠道1/渠道2,注册/充值/下级注册/下级充值/总览/智能汇总多页面数据看板,支持跨页面数据合并 // @author 7zz // @match *://union.firsoo.com/* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ───────────────────────────────────────────── // 常量 & 命名空间 // ───────────────────────────────────────────── const NS = 'hgxd-pro'; const styleId = `${NS}-style`; const panelId = `${NS}-panel`; const btnId = `${NS}-btn`; const layoutStorageKey = `${NS}:layout`; const rectStorageKey = `${NS}:rect`; const aliasStorageKey = `${NS}:alias`; const dataStorageKey = `${NS}:persistData`; // 持久化数据存储键 // ───────────────────────────────────────────── // 日志工具 // ───────────────────────────────────────────── const LOG_PREFIX = '[HGXD-Pro]'; const log = { info: (...a) => console.log (`%c${LOG_PREFIX}`, 'color:#009def;font-weight:bold', ...a), warn: (...a) => console.warn (`%c${LOG_PREFIX}`, 'color:#f59e0b;font-weight:bold', ...a), error: (...a) => console.error (`%c${LOG_PREFIX}`, 'color:#ef4444;font-weight:bold', ...a), success: (...a) => console.log (`%c${LOG_PREFIX} ✓`, 'color:#10b981;font-weight:bold', ...a), group: (l) => console.groupCollapsed(`%c${LOG_PREFIX} ${l}`, 'color:#009def;font-weight:bold'), groupEnd: () => console.groupEnd(), table: (d) => console.table(d), }; // ───────────────────────────────────────────── // 状态(按职责分组) // ───────────────────────────────────────────── /** 数据状态 */ const dataState = { pageType: '', headers: [], rows: [], filteredRows: [], idx: {}, backendStats: {}, columnVisible:{}, regDedupeEnabled: true, regDedupeRemoved: 0, }; /** UI 状态 */ const uiState = { search: '', days: 'all', pageSize: 50, page: 1, sort: { idx: -1, desc: true }, layout: 'drawer', // 'drawer' | 'center' | 'full' rect: null, // 当前布局保存的 { left,top,width,height } dragging: false, panelOpen:false, activeTab: 'data', // 'data' | 'summary' - 当前激活的标签页 }; /** 采集状态 */ const collectState = { collecting: false, collectedPages:0, status: '', abortFlag: false, // 用于取消采集 }; // 推广员别名(支持运行时编辑,持久化到 localStorage) let promoterAlias = loadAlias(); // ───────────────────────────────────────────── // 数据持久化模块(跨页面数据合并) // ───────────────────────────────────────────── /** * 持久化数据结构: * { * reg: { rows: [], idx: {}, collectedAt: timestamp, channel: '1', admin: false }, * pay: { rows: [], idx: {}, collectedAt: timestamp, channel: '1', admin: false }, * } */ function loadPersistData() { try { const raw = localStorage.getItem(dataStorageKey); if (raw) return JSON.parse(raw); } catch (_) {} return { reg: null, pay: null }; } function savePersistData(data) { try { localStorage.setItem(dataStorageKey, JSON.stringify(data)); } catch (_) {} } function saveCurrentPageData() { const data = loadPersistData(); const channel = getChannelType(); const admin = isAdminBackend(); const payload = { rows: dataState.rows, idx: dataState.idx, headers: dataState.headers, collectedAt: Date.now(), channel, admin, }; if (dataState.pageType === 'qudao_reg') { data.reg = payload; log.success('注册数据已保存到本地存储'); } else if (dataState.pageType === 'qudao_pay') { data.pay = payload; log.success('充值数据已保存到本地存储'); } savePersistData(data); } function clearPersistData() { try { localStorage.removeItem(dataStorageKey); log.info('已清除本地存储数据'); } catch (_) {} } function getPersistDataInfo() { const data = loadPersistData(); const info = []; if (data.reg?.rows?.length) { const t = new Date(data.reg.collectedAt); info.push(`注册: ${data.reg.rows.length}条 (${t.toLocaleString()}) [渠道${data.reg.channel}]`); } if (data.pay?.rows?.length) { const t = new Date(data.pay.collectedAt); info.push(`充值: ${data.pay.rows.length}条 (${t.toLocaleString()}) [渠道${data.pay.channel}]`); } return info; } // ───────────────────────────────────────────── // 智能汇总计算引擎 // ───────────────────────────────────────────── /** * 计算推广员维度的汇总数据 * 返回: Map */ function computePromoterSummary() { const persist = loadPersistData(); const result = new Map(); // 检查是否有推广员数据 const hasPromoterReg = persist.reg?.idx?.promoter >= 0; const hasPromoterPay = persist.pay?.idx?.promoter >= 0; const hasPromoterData = hasPromoterReg || hasPromoterPay; // 处理注册数据 if (persist.reg?.rows?.length) { const { rows, idx } = persist.reg; const promoterIdx = idx.promoter; const userIdx = idx.user; const roleIdIdx = idx.roleId; const admin = persist.reg.admin; // 是否总后台 rows.forEach(r => { // 如果有推广员字段,使用字段值;否则判断数据来源 let promoter; if (promoterIdx >= 0) { promoter = normalizeText(r[promoterIdx]) || '(空)'; } else { // 个人后台没有推广员字段,标记为"全部数据" promoter = admin ? '(总后台数据)' : '(全部数据)'; } // 应用别名 if (promoterAlias[promoter]) promoter = promoterAlias[promoter]; if (!result.has(promoter)) { result.set(promoter, { regCount: 0, regPeople: new Set(), payAmount: 0, payCount: 0, payPeople: new Set() }); } const rec = result.get(promoter); rec.regCount++; // 用账号或角色ID去重统计人数 const userKey = normalizeText(r[userIdx]) || normalizeText(r[roleIdIdx]); if (userKey) rec.regPeople.add(userKey); }); } // 处理充值数据 if (persist.pay?.rows?.length) { const { rows, idx } = persist.pay; const promoterIdx = idx.promoter; const amountIdx = idx.amount; const roleIdx = idx.role || idx.user; const admin = persist.pay.admin; rows.forEach(r => { let promoter; if (promoterIdx >= 0) { promoter = normalizeText(r[promoterIdx]) || '(空)'; } else { promoter = admin ? '(总后台数据)' : '(全部数据)'; } // 应用别名 if (promoterAlias[promoter]) promoter = promoterAlias[promoter]; if (!result.has(promoter)) { result.set(promoter, { regCount: 0, regPeople: new Set(), payAmount: 0, payCount: 0, payPeople: new Set() }); } const rec = result.get(promoter); rec.payAmount += toNumber(r[amountIdx]); rec.payCount++; const roleKey = normalizeText(r[roleIdx]); if (roleKey) rec.payPeople.add(roleKey); }); } // 计算派生指标 result.forEach((rec, name) => { rec.regPeopleCount = rec.regPeople.size; rec.payPeopleCount = rec.payPeople.size; rec.arpu = rec.regPeopleCount > 0 ? rec.payAmount / rec.regPeopleCount : 0; rec.payAvg = rec.payCount > 0 ? rec.payAmount / rec.payCount : 0; rec.conversionRate = rec.regPeopleCount > 0 ? (rec.payPeopleCount / rec.regPeopleCount * 100) : 0; // 清理 Set 以便序列化 delete rec.regPeople; delete rec.payPeople; }); return result; } /** * 计算按日期维度的汇总 */ function computeDailySummary() { const persist = loadPersistData(); const result = new Map(); // 处理注册数据 if (persist.reg?.rows?.length) { const { rows, idx } = persist.reg; const regTimeIdx = idx.regTime; const p = n => String(n).padStart(2, '0'); rows.forEach(r => { const d = parseDate(r[regTimeIdx]); if (!d) return; const day = `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`; if (!result.has(day)) { result.set(day, { day, regCount: 0, payAmount: 0, payCount: 0 }); } result.get(day).regCount++; }); } // 处理充值数据 if (persist.pay?.rows?.length) { const { rows, idx } = persist.pay; const payTimeIdx = idx.payTime; const amountIdx = idx.amount; const p = n => String(n).padStart(2, '0'); rows.forEach(r => { const d = parseDate(r[payTimeIdx]); if (!d) return; const day = `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`; if (!result.has(day)) { result.set(day, { day, regCount: 0, payAmount: 0, payCount: 0 }); } const rec = result.get(day); rec.payAmount += toNumber(r[amountIdx]); rec.payCount++; }); } // 计算派生指标并排序 const arr = Array.from(result.values()).map(r => ({ ...r, arpu: r.regCount > 0 ? r.payAmount / r.regCount : 0, })).sort((a, b) => b.day.localeCompare(a.day)); return arr; } /** * 计算总体汇总 */ function computeTotalSummary() { const persist = loadPersistData(); const result = { regCount: 0, regPeopleCount: 0, payAmount: 0, payCount: 0, payPeopleCount: 0, promoterCount: 0, }; const regPeopleSet = new Set(); const payPeopleSet = new Set(); if (persist.reg?.rows?.length) { result.regCount = persist.reg.rows.length; const { rows, idx } = persist.reg; const userIdx = idx.user; const roleIdIdx = idx.roleId; rows.forEach(r => { const key = normalizeText(r[userIdx]) || normalizeText(r[roleIdIdx]); if (key) regPeopleSet.add(key); }); result.regPeopleCount = regPeopleSet.size; } if (persist.pay?.rows?.length) { result.payCount = persist.pay.rows.length; const { rows, idx } = persist.pay; const amountIdx = idx.amount; const roleIdx = idx.role || idx.user; rows.forEach(r => { result.payAmount += toNumber(r[amountIdx]); const key = normalizeText(r[roleIdx]); if (key) payPeopleSet.add(key); }); result.payPeopleCount = payPeopleSet.size; } const promoterSummary = computePromoterSummary(); result.promoterCount = promoterSummary.size; result.arpu = result.regPeopleCount > 0 ? result.payAmount / result.regPeopleCount : 0; result.payAvg = result.payCount > 0 ? result.payAmount / result.payCount : 0; result.conversionRate = result.regPeopleCount > 0 ? (result.payPeopleCount / result.regPeopleCount * 100) : 0; return result; } // ───────────────────────────────────────────── // 工具函数 // ───────────────────────────────────────────── function loadAlias() { try { const raw = localStorage.getItem(aliasStorageKey); if (raw) return JSON.parse(raw); } catch (_) {} // 无默认映射,用户自行添加 return {}; } function saveAlias() { try { localStorage.setItem(aliasStorageKey, JSON.stringify(promoterAlias)); } catch (_) {} } function normalizeText(v) { if (v == null || v === '') return ''; return String(v).replace(/\s+/g, ' ').trim(); } function toNumber(v) { if (typeof v === 'number') return Number.isFinite(v) ? v : 0; if (!v) return 0; const n = parseFloat(String(v).replace(/[^\d.-]/g, '')); return Number.isFinite(n) ? n : 0; } function parseStrictNumber(v) { const s = String(v || '').replace(/,/g, '').trim(); if (!s || !/^-?\d+(\.\d+)?$/.test(s)) return null; const n = Number(s); return Number.isFinite(n) ? n : null; } // LRU-like 日期缓存(上限 3000,超出时清掉最旧的 500 条) const dateCache = new Map(); const DATE_CACHE_MAX = 3000; const DATE_CACHE_TRIM = 500; function parseDate(v) { const s = normalizeText(v); if (!s) return null; if (dateCache.has(s)) return dateCache.get(s); const day = s.match(/\d{4}-\d{2}-\d{2}/)?.[0]; let result = null; if (day) { const hasTime = /\d{2}:\d{2}:\d{2}/.test(s); const iso = hasTime ? `${day}T${s.match(/\d{2}:\d{2}:\d{2}/)[0]}` : `${day}T00:00:00`; const d = new Date(iso); if (!Number.isNaN(d.getTime())) result = d; } if (dateCache.size >= DATE_CACHE_MAX) { // 删掉最旧的 DATE_CACHE_TRIM 条 let i = 0; for (const k of dateCache.keys()) { dateCache.delete(k); if (++i >= DATE_CACHE_TRIM) break; } } dateCache.set(s, result); return result; } function fmtNum(v, digits = 0) { return Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function esc(v) { return String(v == null ? '' : v) .replaceAll('&', '&').replaceAll('<', '<') .replaceAll('>', '>').replaceAll('"', '"') .replaceAll("'", ''').replaceAll('/', '/'); } function topN(arr, n = 8) { return arr.slice(0, n); } function groupCount(rows, idx) { if (idx < 0) return []; const m = new Map(); rows.forEach(r => { const k = normalizeText(r[idx]) || '(空)'; m.set(k, (m.get(k) || 0) + 1); }); return Array.from(m.entries()).sort((a, b) => b[1] - a[1]); } function groupSum(rows, idxKey, idxVal) { if (idxKey < 0 || idxVal < 0) return []; const m = new Map(); rows.forEach(r => { const k = normalizeText(r[idxKey]) || '(空)'; m.set(k, (m.get(k) || 0) + toNumber(r[idxVal])); }); return Array.from(m.entries()).sort((a, b) => b[1] - a[1]); } function groupByDay(rows, idxDay, idxAmount = -1) { if (idxDay < 0) return []; const m = new Map(); rows.forEach(r => { const d = parseDate(r[idxDay]); const p = (n) => String(n).padStart(2, '0'); const key = d ? `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}` : '(未知日期)'; const o = m.get(key) || { day: key, count: 0, amount: 0 }; o.count++; if (idxAmount >= 0) o.amount += toNumber(r[idxAmount]); m.set(key, o); }); return Array.from(m.values()).sort((a, b) => b.day.localeCompare(a.day)); } function guessIndex(headers, patterns) { for (let i = 0; i < headers.length; i++) { if (patterns.some(re => re.test(headers[i]))) return i; } return -1; } function debounce(fn, ms) { let timer = null; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } // ───────────────────────────────────────────── // 布局 & 位置管理 // ───────────────────────────────────────────── function loadLayoutPrefs() { try { const layout = localStorage.getItem(layoutStorageKey); if (layout && ['drawer','center','full'].includes(layout)) uiState.layout = layout; const rect = localStorage.getItem(rectStorageKey); if (rect) uiState.rect = JSON.parse(rect); } catch (_) {} } function saveLayoutPrefs() { try { localStorage.setItem(layoutStorageKey, uiState.layout); if (uiState.rect) localStorage.setItem(rectStorageKey, JSON.stringify(uiState.rect)); } catch (_) {} } function getSidebarWidth() { return Math.ceil(document.querySelector('.sdk_left')?.getBoundingClientRect().width || 0); } function defaultRect(layout) { const vw = window.innerWidth, vh = window.innerHeight, side = getSidebarWidth(); if (layout === 'center') { const width = Math.max(760, Math.min(1280, vw - side - 28)); const height = Math.max(500, vh - 28); const left = Math.max(side + 10, Math.round(side + (vw - side - width) / 2)); return { left, top: 14, width, height }; } const width = Math.min(1200, Math.max(760, Math.floor(vw * 0.62))); const height = Math.max(500, vh - 24); const left = Math.max(side + 10, vw - width - 12); return { left, top: 12, width, height }; } function clampRect(rect) { const vw = window.innerWidth, vh = window.innerHeight; const width = Math.min(Math.max(Math.round(rect.width || 620), 620), vw - 8); const height = Math.min(Math.max(Math.round(rect.height || 420), 420), vh - 8); const left = Math.min(Math.max(Math.round(rect.left || 4), 4), vw - width - 4); const top = Math.min(Math.max(Math.round(rect.top || 4), 4), vh - height - 4); return { left, top, width, height }; } function applyRect(rect) { const panel = document.getElementById(panelId); if (!panel) return; const r = clampRect(rect); panel.style.cssText += `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;`; } function getCurrentRect() { const panel = document.getElementById(panelId); if (!panel) return null; const r = panel.getBoundingClientRect(); return { left: r.left, top: r.top, width: r.width, height: r.height }; } function saveCurrentRect() { if (uiState.layout === 'full') return; const now = getCurrentRect(); if (now) { uiState.rect = clampRect(now); saveLayoutPrefs(); } } function applyPanelLayout(useSaved = true) { const panel = document.getElementById(panelId); if (!panel) return; if (uiState.layout === 'full') { panel.classList.add('hgxd-layout-full'); ['left','top','width','height'].forEach(p => panel.style[p] = ''); saveLayoutPrefs(); return; } panel.classList.remove('hgxd-layout-full'); const rect = (useSaved && uiState.rect) ? uiState.rect : defaultRect(uiState.layout); applyRect(rect); saveCurrentRect(); } function cycleLayout() { saveCurrentRect(); // 先保存旧布局的位置 if (uiState.layout === 'drawer') uiState.layout = 'center'; else if (uiState.layout === 'center') uiState.layout = 'full'; else uiState.layout = 'drawer'; applyPanelLayout(false); saveLayoutPrefs(); // 只需更新顶栏按钮文字,不必重建整个面板 const btn = document.getElementById(`${NS}-layout`); if (btn) btn.textContent = getLayoutLabel(); } function getLayoutLabel() { if (uiState.layout === 'drawer') return '⊕ 右侧'; if (uiState.layout === 'center') return '⊑ 居中'; return '⛶ 全屏'; } // ───────────────────────────────────────────── // 页面类型 & URL 工具 // ───────────────────────────────────────────── function isAdminBackend() { const ac = new URLSearchParams(location.search).get('ac') || ''; if (/^qudao_d_/.test(ac)) return true; if (document.querySelector('a[href*="qudao_d_"]')) return true; return false; } function getPageTypeByUrl() { const ac = new URLSearchParams(location.search).get('ac') || ''; if (ac === 'qudao_pay' || ac === 'qudao_d_pay') return 'qudao_pay'; if (ac === 'qudao_reg' || ac === 'qudao_d_reg') return 'qudao_reg'; if (ac === 'zonglan') return 'zonglan'; if (ac === 'huizong') return 'huizong'; return ''; } function getChannelType() { return new URLSearchParams(location.search).get('type') || '1'; } function getAcParam(pageType) { const admin = isAdminBackend(); if (pageType === 'qudao_reg') return admin ? 'qudao_d_reg' : 'qudao_reg'; if (pageType === 'qudao_pay') return admin ? 'qudao_d_pay' : 'qudao_pay'; return pageType; } function buildNavUrl(pageType) { const url = new URL(location.href); url.searchParams.set('ac', getAcParam(pageType)); if (pageType === 'huizong') url.searchParams.delete('type'); ['p','user_name','server_name','role_name','charid','order_time_start','order_time_end'] .forEach(k => url.searchParams.delete(k)); return url.toString(); } function getPageTypeByHeaders(headers) { const hs = headers.join('|'); if (/订单号/.test(hs) && /订单金额|充值金额|支付渠道/.test(hs)) return 'qudao_pay'; if (/角色等级/.test(hs) && /注册时间/.test(hs)) return 'qudao_reg'; if (/注册数量/.test(hs) && /时间/.test(hs)) return 'zonglan'; if (/推广员/.test(hs) && /ARPU/.test(hs)) return 'huizong'; return ''; } function getPageTypeLabel() { const admin = isAdminBackend(), ch = getChannelType(); const chLabel = ch === '2' ? ' [渠道2]' : ''; if (dataState.pageType === 'qudao_pay') return (admin ? '下级充值' : '充值记录') + chLabel; if (dataState.pageType === 'qudao_reg') return (admin ? '下级角色' : '角色记录') + chLabel; if (dataState.pageType === 'zonglan') return '数据总览' + chLabel; if (dataState.pageType === 'huizong') return '数据汇总'; return '通用页'; } // ───────────────────────────────────────────── // 表格数据提取 // ───────────────────────────────────────────── function findMainTable() { const tables = Array.from(document.querySelectorAll('table.layui-table')); if (!tables.length) return null; return tables.reduce((best, t) => { const s = t.querySelectorAll('thead th').length * 100 + t.querySelectorAll('tbody tr').length; return (!best || s > best._score) ? (t._score = s, t) : best; }, null); } function syncColumnVisibility() { const next = {}; dataState.headers.forEach(h => { next[h] = Object.prototype.hasOwnProperty.call(dataState.columnVisible, h) ? dataState.columnVisible[h] : true; }); dataState.columnVisible = next; } function applyPromoterAlias(rows) { const pIdx = dataState.idx.promoter; if (pIdx < 0) return; rows.forEach(r => { const raw = normalizeText(r[pIdx]); if (promoterAlias[raw]) r[pIdx] = promoterAlias[raw]; }); } function extractTableData() { const table = findMainTable(); if (!table) { log.warn('未找到可用表格 (table.layui-table)'); return false; } const headerEls = Array.from(table.querySelectorAll('thead th')); const rowEls = Array.from(table.querySelectorAll('tbody tr')); if (!headerEls.length || !rowEls.length) { log.warn('表格没有表头或数据行'); return false; } const headers = headerEls.map(th => normalizeText(th.textContent)); const rows = rowEls .map(tr => { const cells = Array.from(tr.querySelectorAll('td')).map(td => normalizeText(td.textContent)); while (cells.length < headers.length) cells.push(''); cells.length = headers.length; return cells; }) .filter(r => r.some(v => v !== '')); if (!rows.length) return false; dataState.headers = headers; dataState.rows = rows; dataState.backendStats = parseBackendStats(); dataState.pageType = getPageTypeByUrl() || getPageTypeByHeaders(headers) || 'generic'; dataState.idx = { order: guessIndex(headers, [/订单号/i]), user: guessIndex(headers, [/玩家用户名/i, /^用户名$/i, /账号/i, /user/i]), game: guessIndex(headers, [/游戏名称/i, /^游戏$/i, /games?/i]), server: guessIndex(headers, [/区服/i, /服务器/i]), role: guessIndex(headers, [/角色名/i, /角色名称/i, /role\s*name/i]), roleId: guessIndex(headers, [/角色ID/i, /charid/i]), level: guessIndex(headers, [/角色等级/i, /等级/i]), amount: guessIndex(headers, [/订单金额/i, /充值金额/i, /金额/i, /money/i]), currency: guessIndex(headers, [/货币/i, /currency/i]), product: guessIndex(headers, [/商品/i, /礼包/i]), channel: guessIndex(headers, [/支付渠道/i, /渠道/i, /channel/i]), payTime: guessIndex(headers, [/充值时间/i]), regTime: guessIndex(headers, [/注册时间/i]), loginTime: guessIndex(headers, [/登录时间/i]), promoter: guessIndex(headers, [/所属推广员/i, /推广员/i, /推广/i]), day: guessIndex(headers, [/^时间$/i, /日期/i]), regCount: guessIndex(headers, [/注册数量/i]), payCount: guessIndex(headers, [/充值数量/i, /充值金额/i]), hzRegPeople: guessIndex(headers, [/注册人数/i]), hzRegRoles: guessIndex(headers, [/注册角色数/i]), hzPayPeople: guessIndex(headers, [/充值人数/i]), hzPayAmount: guessIndex(headers, [/^充值金额$/i]), hzRealPay: guessIndex(headers, [/实际支付/i]), hzOrders: guessIndex(headers, [/订单数/i]), hzArpu: guessIndex(headers, [/ARPU/i]), }; applyPromoterAlias(dataState.rows); syncColumnVisibility(); const timeIdx = dataState.idx.payTime >= 0 ? dataState.idx.payTime : dataState.idx.regTime >= 0 ? dataState.idx.regTime : dataState.idx.day; uiState.sort = { idx: timeIdx >= 0 ? timeIdx : 0, desc: true }; uiState.page = 1; collectState.collectedPages = 0; collectState.status = ''; log.success(`提取完成: ${rows.length} 行, ${headers.length} 列, 类型=${dataState.pageType}`); log.group('字段映射'); log.table(Object.fromEntries( Object.entries(dataState.idx).filter(([,v]) => v >= 0).map(([k,v]) => [k, `${v} (${headers[v]})`]) )); log.groupEnd(); return true; } // ───────────────────────────────────────────── // 分页采集 // ───────────────────────────────────────────── function detectSitePages() { const pager = document.querySelector('.pages'); if (!pager) return null; const currentEl = pager.querySelector('span.current'); const current = currentEl ? Number(currentEl.textContent.trim()) : 1; if (!Number.isFinite(current) || current < 1) return null; const lastLink = pager.querySelector('a.last-page'); if (!lastLink?.href) return null; const m = lastLink.href.match(/[?&]p=(\d+)/); if (!m) return null; const total = Number(m[1]); if (!Number.isFinite(total) || total < 2) return null; const pageUrls = []; for (let p = 1; p <= total; p++) { pageUrls.push(p === current ? null : lastLink.href.replace(/([?&]p=)\d+/, `$1${p}`)); } return { current, total, pageUrls }; } async function fetchPageRows(url, signal) { const resp = await fetch(url, { credentials: 'include', signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const html = await resp.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const tables = Array.from(doc.querySelectorAll('table.layui-table')); if (!tables.length) return []; const best = tables.reduce((b, t) => { const s = t.querySelectorAll('thead th').length * 100 + t.querySelectorAll('tbody tr').length; return (!b || s > b._score) ? (t._score = s, t) : b; }, null); if (!best) return []; const hdrLen = dataState.headers.length; return Array.from(best.querySelectorAll('tbody tr')) .map(tr => { const cells = Array.from(tr.querySelectorAll('td')).map(td => normalizeText(td.textContent)); while (cells.length < hdrLen) cells.push(''); cells.length = hdrLen; return cells; }) .filter(r => r.some(v => v !== '')); } // 并发采集(最多 N 个并发请求) async function collectAllPages() { if (collectState.collecting) return; extractTableData(); // 每次重新读取当前页,防止数据叠加 const info = detectSitePages(); if (!info) { alert('未检测到分页信息,当前可能只有一页或分页尚未加载。'); return; } log.info(`开始采集: 共 ${info.total} 页`); collectState.collecting = true; collectState.abortFlag = false; collectState.collectedPages = 0; collectState.status = `采集中… 1/${info.total}`; // 用 AbortController 支持取消 const ac = new AbortController(); collectState._abort = () => { collectState.abortFlag = true; ac.abort(); }; updateCollectUI(); const allRows = [...dataState.rows]; const remoteUrls = info.pageUrls.filter(Boolean); let done = 1; const CONCURRENCY = 3; // 并发数,避免对服务器压力过大 // 分批并发 for (let i = 0; i < remoteUrls.length; i += CONCURRENCY) { if (collectState.abortFlag) break; const batch = remoteUrls.slice(i, i + CONCURRENCY); const results = await Promise.allSettled( batch.map(url => fetchPageRows(url, ac.signal)) ); results.forEach((r, bi) => { if (r.status === 'fulfilled') { allRows.push(...r.value); } else { log.error(`采集分页失败: ${batch[bi]}`, r.reason); } done++; const pct = Math.floor((done / info.total) * 100); collectState.status = `采集中… ${done}/${info.total} (${pct}%)`; updateCollectUI(); }); } dataState.rows = allRows; applyPromoterAlias(dataState.rows); collectState.collecting = false; collectState.collectedPages = info.total; collectState.status = collectState.abortFlag ? `已取消,已采集 ${done} 页,共 ${allRows.length} 条` : `已采集 ${info.total} 页,共 ${allRows.length} 条`; log.success(collectState.status); // 采集完成后自动保存数据 saveCurrentPageData(); uiState.page = 1; renderDataArea(); // 只更新数据区,不重建整个面板 updateCollectUI(); } // 仅更新采集按钮区域 function updateCollectUI() { const wrap = document.getElementById(`${NS}-collect-wrap`); if (!wrap) return; wrap.innerHTML = buildCollectHtml(); } function buildCollectHtml() { if (collectState.collecting) { return ` ⏳ ${esc(collectState.status)} `; } const done = collectState.collectedPages > 1 ? `✅ ${esc(collectState.status)}` : ''; return ` ${done} `; } // ───────────────────────────────────────────── // 数据过滤 & 排序(带缓存) // ───────────────────────────────────────────── // 去重注册行 function keepNewerRegRow(a, b, idx) { const cmp = (ia, ib) => { const da = ia >= 0 ? parseDate(a.row[ia]) : null; const db = ib >= 0 ? parseDate(b.row[ib]) : null; if (da && db && da.getTime() !== db.getTime()) return da > db ? a : b; if (da && !db) return a; if (!da && db) return b; return null; }; return cmp(idx.regTime, idx.regTime) || cmp(idx.loginTime, idx.loginTime) || (() => { const av = idx.level >= 0 ? toNumber(a.row[idx.level]) : -Infinity; const bv = idx.level >= 0 ? toNumber(b.row[idx.level]) : -Infinity; return av !== bv ? (av > bv ? a : b) : (a.pos > b.pos ? a : b); })(); } function dedupeRegRowsByRoleId(rows, idx) { if (idx.roleId < 0) return { rows, removed: 0 }; const keyed = new Map(); const noKey = []; rows.forEach((row, pos) => { const key = normalizeText(row[idx.roleId]); if (!key) { noKey.push({ row, pos }); return; } const candidate = { row, pos }; keyed.set(key, keyed.has(key) ? keepNewerRegRow(keyed.get(key), candidate, idx) : candidate); }); const kept = noKey.concat(Array.from(keyed.values())) .sort((a, b) => a.pos - b.pos).map(x => x.row); return { rows: kept, removed: Math.max(0, rows.length - kept.length) }; } function applyFiltersAndSort() { const { rows, idx } = dataState; const { search, days, sort } = uiState; let out = rows; if (dataState.pageType === 'qudao_reg' && dataState.regDedupeEnabled) { const r = dedupeRegRowsByRoleId(out, idx); out = r.rows; dataState.regDedupeRemoved = r.removed; } else { dataState.regDedupeRemoved = 0; } if (search) { const key = search.toLowerCase(); out = out.filter(r => r.some(v => String(v).toLowerCase().includes(key))); } const timeIdx = idx.payTime >= 0 ? idx.payTime : idx.regTime >= 0 ? idx.regTime : idx.day; if (days !== 'all' && timeIdx >= 0) { const n = Number(days); if (Number.isFinite(n) && n > 0) { const start = new Date(); start.setHours(0,0,0,0); start.setDate(start.getDate() - (n - 1)); out = out.filter(r => { const d = parseDate(r[timeIdx]); return d ? d >= start : false; }); } } if (sort.idx >= 0 && out.length > 1) { out = [...out].sort((a, b) => { const av = a[sort.idx], bv = b[sort.idx]; const ad = parseDate(av), bd = parseDate(bv); if (ad && bd) return sort.desc ? bd - ad : ad - bd; const an = parseStrictNumber(av), bn = parseStrictNumber(bv); if (an != null && bn != null) return sort.desc ? bn - an : an - bn; return sort.desc ? String(bv).localeCompare(String(av), 'zh-CN') : String(av).localeCompare(String(bv), 'zh-CN'); }); } dataState.filteredRows = out; } // ───────────────────────────────────────────── // 统计卡片构建 // ───────────────────────────────────────────── function buildCards() { const rows = dataState.filteredRows; const idx = dataState.idx; const bs = dataState.backendStats || {}; if (dataState.pageType === 'qudao_pay') { const amount = rows.reduce((s, r) => s + (idx.amount >= 0 ? toNumber(r[idx.amount]) : 0), 0); const roleIdx = idx.role >= 0 ? idx.role : idx.user; const uniq = roleIdx >= 0 ? new Set(rows.map(r => normalizeText(r[roleIdx]))).size : 0; const currencies= idx.currency >= 0 ? [...new Set(rows.map(r => normalizeText(r[idx.currency])).filter(Boolean))] : []; const currLabel = currencies.length === 1 ? ` (${currencies[0]})` : currencies.length > 1 ? ' (混合)' : ''; return [ { k: '充值记录', v: fmtNum(rows.length) }, { k: '后台充值笔数', v: Number.isFinite(bs.payCount) ? fmtNum(bs.payCount) : '-' }, { k: `总金额${currLabel}`, v: idx.amount >= 0 ? fmtNum(amount, 2) : '-' }, { k: '后台充值金额', v: Number.isFinite(bs.payAmount) ? fmtNum(bs.payAmount, 2) : '-' }, { k: '付费角色数', v: roleIdx >= 0 ? fmtNum(uniq) : '-' }, { k: '单笔均额', v: (idx.amount >= 0 && rows.length) ? fmtNum(amount / rows.length, 2) : '-' }, ]; } if (dataState.pageType === 'qudao_reg') { const uniqUser = idx.user >= 0 ? new Set(rows.map(r => normalizeText(r[idx.user]))).size : 0; const p = n => String(n).padStart(2, '0'); const fmtLocal = d => `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; const regTimes = idx.regTime >= 0 ? rows.map(r => ({ t: parseDate(r[idx.regTime]), r })).filter(x => x.t).sort((a,b) => b.t - a.t) : []; const latestTime = regTimes.length ? fmtLocal(regTimes[0].t) : '-'; const latestRole = regTimes.length ? (idx.role >= 0 ? normalizeText(regTimes[0].r[idx.role]) : '') || (idx.user >= 0 ? normalizeText(regTimes[0].r[idx.user]) : '') : ''; return [ { k: '注册记录', v: fmtNum(rows.length) }, { k: '后台注册人数', v: Number.isFinite(bs.regPeople) ? fmtNum(bs.regPeople) : '-' }, { k: '去重账号', v: idx.user >= 0 ? fmtNum(uniqUser) : '-' }, { k: '去重剔除', v: dataState.regDedupeEnabled ? fmtNum(dataState.regDedupeRemoved) : '-' }, { k: `最近注册${latestRole ? ':' + latestRole : ''}`, v: latestTime }, ]; } if (dataState.pageType === 'zonglan') { const nr = rows.filter(r => normalizeText(r[idx.day]) !== '总计'); const reg = idx.regCount >= 0 ? nr.reduce((s, r) => s + toNumber(r[idx.regCount]), 0) : 0; const pay = idx.payCount >= 0 ? nr.reduce((s, r) => s + toNumber(r[idx.payCount]), 0) : 0; return [ { k: '统计天数', v: fmtNum(nr.length) }, { k: '注册总数', v: idx.regCount >= 0 ? fmtNum(reg) : '-' }, { k: '充值总额', v: idx.payCount >= 0 ? fmtNum(pay, 2) : '-' }, { k: '日均注册', v: (idx.regCount >= 0 && nr.length) ? fmtNum(reg / nr.length, 1) : '-' }, { k: '人均充值', v: (reg > 0 && idx.payCount >= 0) ? fmtNum(pay / reg, 3) : '-' }, ]; } if (dataState.pageType === 'huizong') { const totalReg = idx.hzRegPeople >= 0 ? rows.reduce((s,r) => s + toNumber(r[idx.hzRegPeople]), 0) : 0; const totalPay = idx.hzPayAmount >= 0 ? rows.reduce((s,r) => s + toNumber(r[idx.hzPayAmount]), 0) : 0; const totalPayPeople = idx.hzPayPeople >= 0 ? rows.reduce((s,r) => s + toNumber(r[idx.hzPayPeople]), 0) : 0; const totalOrders = idx.hzOrders >= 0 ? rows.reduce((s,r) => s + toNumber(r[idx.hzOrders]), 0) : 0; return [ { k: '推广员数', v: fmtNum(rows.length) }, { k: '总注册人数', v: fmtNum(totalReg) }, { k: '总充值金额', v: fmtNum(totalPay, 2) }, { k: '总充值人数', v: fmtNum(totalPayPeople) }, { k: '总订单数', v: fmtNum(totalOrders) }, ]; } return [ { k: '记录数', v: fmtNum(rows.length) }, { k: '字段数', v: fmtNum(dataState.headers.length) }, ]; } // ───────────────────────────────────────────── // 洞察面板构建(按 pageType 拆分) // ───────────────────────────────────────────── function barHtml(items, digits = 0, color = '#2f8de4') { if (!items.length) return '
暂无数据
'; const max = items[0][1] || 1; return items.map(([name, val]) => { const n = Number(val || 0); const pct = Math.max(6, Math.round((n / max) * 100)); return `
${esc(name)}
${fmtNum(n, digits)}
`; }).join(''); } function buildPayInsights(rows, idx) { const roleKey = idx.role >= 0 ? idx.role : idx.user; const roleTitle = idx.role >= 0 ? '角色金额 Top 8' : '用户金额 Top 8'; const roleAmount = topN(groupSum(rows, roleKey, idx.amount)); const channelCnt = topN(groupCount(rows, idx.channel)); const byDay = groupByDay(rows, idx.payTime, idx.amount); const trendRows = byDay.slice(0, 14).map(d => `${esc(d.day)}${fmtNum(d.count)}${fmtNum(d.amount, 2)}` ).join(''); return [ { title: roleTitle, body: barHtml(roleAmount, 2, '#0b78d0') }, { title: '支付渠道分布 Top 8', body: barHtml(channelCnt, 0, '#531dab') }, { title: '充值日趋势', body: `
${trendRows || ''}
日期条数金额
暂无数据
`, }, ]; } function buildRegInsights(rows, idx) { // 登录汇总 const loginSummary = (() => { if (idx.loginTime < 0) return { today: 0, month: 0 }; const now = new Date(), y = now.getFullYear(), m = now.getMonth(); const keyIdx = idx.roleId >= 0 ? idx.roleId : (idx.role >= 0 ? idx.role : idx.user); const todaySet = new Set(), monthSet = new Set(); rows.forEach(r => { const d = parseDate(r[idx.loginTime]); const key = keyIdx >= 0 ? normalizeText(r[keyIdx]) : null; if (!d) return; if (d.getFullYear() === y && d.getMonth() === m) { if (key) { monthSet.add(key); if (d.getDate() === now.getDate()) todaySet.add(key); } else { monthSet.add(Symbol()); if (d.getDate() === now.getDate()) todaySet.add(Symbol()); } } }); return { today: todaySet.size, month: monthSet.size }; })(); const loginCompare = (() => { if (idx.loginTime < 0) return { yesterday: 0, prevMonth: 0 }; const now = new Date(), y = now.getFullYear(), m = now.getMonth(); const yest = new Date(now); yest.setDate(now.getDate() - 1); const py = m === 0 ? y-1 : y, pm = m === 0 ? 11 : m-1; const keyIdx = idx.roleId >= 0 ? idx.roleId : (idx.role >= 0 ? idx.role : idx.user); const yesterSet = new Set(), prevMonthSet = new Set(); rows.forEach(r => { const d = parseDate(r[idx.loginTime]); const key = keyIdx >= 0 ? normalizeText(r[keyIdx]) : Symbol(); if (!d || !key) return; if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) yesterSet.add(key); if (d.getFullYear() === py && d.getMonth() === pm) prevMonthSet.add(key); }); return { yesterday: yesterSet.size, prevMonth: prevMonthSet.size }; })(); const calcTrend = (curr, prev) => { if (prev <= 0) return curr <= 0 ? { dir: 'flat', pct: '0.00' } : { dir: 'up', pct: '100.00' }; const raw = ((curr - prev) / prev) * 100; if (Math.abs(raw) < 0.005) return { dir: 'flat', pct: '0.00' }; return { dir: raw > 0 ? 'up' : 'down', pct: Math.abs(raw).toFixed(2) }; }; const now = new Date(), p = n => String(n).padStart(2, '0'); const monthLastDay = new Date(now.getFullYear(), now.getMonth()+1, 0).getDate(); const monthRange = `${now.getFullYear()}-${p(now.getMonth()+1)}-01 至 ${now.getFullYear()}-${p(now.getMonth()+1)}-${p(monthLastDay)}`; const todayTrend = calcTrend(loginSummary.today, loginCompare.yesterday); const monthTrend = calcTrend(loginSummary.month, loginCompare.prevMonth); const trendIcon = d => d === 'up' ? '↑' : d === 'down' ? '↓' : '↑'; // 推广员每日注册 const promoterDailyRows = (() => { if (idx.regTime < 0 || idx.promoter < 0) return ''; const keyIdx = idx.roleId >= 0 ? idx.roleId : (idx.role >= 0 ? idx.role : idx.user); const bucket = new Map(); rows.forEach(r => { const d = parseDate(r[idx.regTime]); if (!d) return; const promoter = normalizeText(r[idx.promoter]) || '(空)'; const day = `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`; const mapKey = `${day}@@${promoter}`; if (!bucket.has(mapKey)) bucket.set(mapKey, { day, promoter, count: 0, uniq: new Set() }); const rec = bucket.get(mapKey); rec.count++; if (keyIdx >= 0) { const pk = normalizeText(r[keyIdx]); if (pk) rec.uniq.add(pk); } }); return Array.from(bucket.values()) .sort((a, b) => String(b.day).localeCompare(String(a.day)) || b.count - a.count) .slice(0, 30) .map(x => `${esc(x.day)}${esc(x.promoter)}${fmtNum(x.count)}${fmtNum(x.uniq.size || x.count)}`) .join(''); })(); const byDay = groupByDay(rows, idx.regTime, -1); const trendRows = byDay.slice(0, 10).map(d => `${esc(d.day)}${fmtNum(d.count)}` ).join(''); return [ { title: '登录汇总', body: idx.loginTime >= 0 ? `
登录用户?

今日

${fmtNum(loginSummary.today)}

${fmtNum(loginCompare.yesterday)} ${trendIcon(todayTrend.dir)} ${todayTrend.pct}%

${esc(monthRange)}

${fmtNum(loginSummary.month)}

${fmtNum(loginCompare.prevMonth)} ${trendIcon(monthTrend.dir)} ${monthTrend.pct}%
` : '
当前页面无登录时间字段
', }, { title: '注册日趋势', body: `
${trendRows || ''}
日期注册条数
暂无数据
`, }, { title: '推广员每日注册明细', body: (idx.regTime >= 0 && idx.promoter >= 0) ? `
${promoterDailyRows || ''}
日期推广员注册数注册人数
暂无数据
` : '
当前页面无推广员或注册时间字段
', }, ]; } function buildZonglanInsights(rows, idx) { const dayRows = rows.filter(r => normalizeText(r[idx.day]) !== '总计'); const regBars = topN(dayRows.map(r => [r[idx.day], toNumber(r[idx.regCount])]).sort((a,b) => b[1]-a[1]), 10); const payBars = topN(dayRows.map(r => [r[idx.day], toNumber(r[idx.payCount])]).sort((a,b) => b[1]-a[1]), 10); const trendRows = dayRows.map(r => { const reg = idx.regCount >= 0 ? toNumber(r[idx.regCount]) : 0; const pay = idx.payCount >= 0 ? toNumber(r[idx.payCount]) : 0; return { day: r[idx.day], reg, pay, arpu: reg > 0 ? pay / reg : 0 }; }).sort((a,b) => String(b.day).localeCompare(String(a.day))).slice(0,14) .map(x => `${esc(x.day)}${fmtNum(x.reg)}${fmtNum(x.pay,2)}${fmtNum(x.arpu,3)}`) .join(''); return [ { title: '日注册 Top 10', body: barHtml(regBars, 0, '#27ae60') }, { title: '日充值 Top 10', body: barHtml(payBars, 2, '#d46b08') }, { title: '按日总览', body: `${trendRows || ''}
日期注册充值人均充值
暂无数据
`, }, ]; } function buildHuizongInsights(rows, idx) { const pIdx = idx.promoter >= 0 ? idx.promoter : 0; const regBars = idx.hzRegPeople >= 0 ? topN(rows.map(r => [normalizeText(r[pIdx]), toNumber(r[idx.hzRegPeople])]).sort((a,b) => b[1]-a[1])) : []; const payBars = idx.hzPayAmount >= 0 ? topN(rows.map(r => [normalizeText(r[pIdx]), toNumber(r[idx.hzPayAmount])]).sort((a,b) => b[1]-a[1])) : []; const arpuBars= idx.hzArpu >= 0 ? topN(rows.map(r => [normalizeText(r[pIdx]), toNumber(r[idx.hzArpu])]).sort((a,b) => b[1]-a[1])) : []; return [ { title: '推广员注册人数', body: barHtml(regBars, 0, '#3b82f6') }, { title: '推广员充值金额', body: barHtml(payBars, 2, '#f59e0b') }, { title: '推广员 ARPU', body: barHtml(arpuBars, 2, '#8b5cf6') }, ]; } const insightBuilders = { qudao_pay: buildPayInsights, qudao_reg: buildRegInsights, zonglan: buildZonglanInsights, huizong: buildHuizongInsights, }; function buildInsightPanels() { const builder = insightBuilders[dataState.pageType]; if (!builder) return [{ title: '字段分布', body: '
当前页面未命中模板,已使用通用展示。
' }]; return builder(dataState.filteredRows, dataState.idx); } // ───────────────────────────────────────────── // 其它辅助 // ───────────────────────────────────────────── function parseBackendStats() { const stats = {}; const top = document.querySelector('.model_top'); if (!top) return stats; const txt = normalizeText(top.textContent); const reg = txt.match(/注册人数[::]\s*(\d+)/); if (reg) stats.regPeople = Number(reg[1]); const pay = txt.match(/充值数量[::]\s*([0-9.]+)\s*[,,]\s*充值金额[::]\s*([0-9.]+)/); if (pay) { stats.payCount = Number(pay[1]); stats.payAmount = Number(pay[2]); } return stats; } function buildBackendMeta() { const bs = dataState.backendStats || {}; if (dataState.pageType === 'qudao_reg' && Number.isFinite(bs.regPeople)) return `后台口径 注册人数: ${fmtNum(bs.regPeople)}`; if (dataState.pageType === 'qudao_pay' && (Number.isFinite(bs.payCount) || Number.isFinite(bs.payAmount))) return `后台口径 充值数量: ${Number.isFinite(bs.payCount) ? fmtNum(bs.payCount) : '-'} / 充值金额: ${Number.isFinite(bs.payAmount) ? fmtNum(bs.payAmount, 2) : '-'}`; return ''; } function getVisibleColumnIndexes() { const idxs = dataState.headers.map((h, i) => dataState.columnVisible[h] !== false ? i : -1).filter(i => i >= 0); return idxs.length ? idxs : dataState.headers.map((_, i) => i); } function exportCsv() { const cols = getVisibleColumnIndexes(); const headers = cols.map(i => dataState.headers[i]); const quote = s => `"${String(s == null ? '' : s).replaceAll('"','""')}"`; const lines = [headers.map(quote).join(',')]; dataState.filteredRows.forEach(r => lines.push(cols.map(i => quote(r[i])).join(','))); const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement('a'), { href: url }); const p = n => String(n).padStart(2, '0'); const now = new Date(); a.download = `火光小岛_${dataState.pageType || 'data'}_${now.getFullYear()}${p(now.getMonth()+1)}${p(now.getDate())}_${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}.csv`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // ───────────────────────────────────────────── // 渲染(拆分为"首次建框架"和"局部更新") // ───────────────────────────────────────────── /** 首次建立面板 DOM 骨架(只调用一次) */ function buildPanelShell() { const panel = document.getElementById(panelId); if (!panel) return; panel.innerHTML = `
`; bindPanelEvents(panel); } /** 更新标签栏 */ function renderTabbar() { const el = document.getElementById(`${NS}-tabbar`); if (!el) return; const persistInfo = getPersistDataInfo(); const infoHtml = persistInfo.length ? `📦 ${persistInfo.join(' | ')}` : '📦 暂无本地数据,请先采集注册/充值数据'; el.innerHTML = `
${infoHtml}
`; } /** 更新顶栏(页面类型变化时才需要) */ function renderTopbar() { const el = document.getElementById(`${NS}-topbar`); if (!el) return; el.innerHTML = `
🔥
火光小岛数据看板 Pro
${esc(getPageTypeLabel())}
${(dataState.pageType==='qudao_reg'||dataState.pageType==='qudao_pay')?` | `:''}
`; } /** 更新筛选栏 */ function renderFilterbar() { const el = document.getElementById(`${NS}-filterbar`); if (!el) return; // 如果是智能汇总标签,隐藏筛选栏 if (uiState.activeTab === 'summary') { el.style.display = 'none'; return; } el.style.display = 'flex'; const backendMeta = buildBackendMeta(); const colChecks = dataState.headers.map(h => `` ).join(''); el.innerHTML = `
${buildCollectHtml()}
${backendMeta ? `${esc(backendMeta)}` : ''}`; } /** 更新统计卡片 */ function renderCards() { const el = document.getElementById(`${NS}-cards`); if (!el) return; el.innerHTML = buildCards().map(c => `
${esc(c.k)}${esc(c.v)}
` ).join(''); } /** 更新洞察面板 */ function renderInsights() { const el = document.getElementById(`${NS}-insights`); if (!el) return; el.innerHTML = buildInsightPanels().map(p => `

${esc(p.title)}

${p.body}
` ).join(''); } /** 更新表格 */ function renderTable() { const scroll = document.getElementById(`${NS}-table-scroll`); if (!scroll) return; const cols = getVisibleColumnIndexes(); const total = dataState.filteredRows.length; const totalPages = Math.max(1, Math.ceil(total / uiState.pageSize)); if (uiState.page > totalPages) uiState.page = totalPages; const start = (uiState.page - 1) * uiState.pageSize; const pageRows = dataState.filteredRows.slice(start, start + uiState.pageSize); const amountAvg = (dataState.pageType === 'qudao_pay' && dataState.idx.amount >= 0 && dataState.filteredRows.length) ? dataState.filteredRows.reduce((s, r) => s + toNumber(r[dataState.idx.amount]), 0) / dataState.filteredRows.length : 0; const thHtml = cols.map(i => { const isSort = uiState.sort.idx === i; const arrow = isSort ? (uiState.sort.desc ? ' ▼' : ' ▲') : ''; return `${esc(dataState.headers[i])}${arrow}`; }).join(''); const tdHtml = pageRows.map(r => `${cols.map(i => { let cls = ''; if (dataState.pageType === 'qudao_pay' && i === dataState.idx.amount && amountAvg > 0 && toNumber(r[i]) >= amountAvg * 2) cls = ' class="hgxd-amount-high"'; return `${esc(r[i])}`; }).join('')}` ).join(''); scroll.innerHTML = ` ${thHtml}${tdHtml || ``}
暂无数据
`; } /** 更新分页器 */ function renderPager() { const el = document.getElementById(`${NS}-pager`); if (!el) return; const total = dataState.filteredRows.length; const totalPages = Math.max(1, Math.ceil(total / uiState.pageSize)); el.innerHTML = ` 第 ${uiState.page} / ${totalPages} 页,共 ${fmtNum(total)} 条 `; } /** 只更新数据区(翻页、排序、筛选时调用) */ function renderDataArea() { applyFiltersAndSort(); renderCards(); renderInsights(); renderTable(); renderPager(); } /** 切换标签 */ function switchTab() { const mainBody = document.getElementById(`${NS}-main-body`); const summaryBody = document.getElementById(`${NS}-summary-body`); if (uiState.activeTab === 'summary') { if (mainBody) mainBody.style.display = 'none'; if (summaryBody) summaryBody.style.display = 'block'; renderSummaryTab(); } else { if (mainBody) mainBody.style.display = 'block'; if (summaryBody) summaryBody.style.display = 'none'; } renderFilterbar(); } /** 渲染智能汇总标签 */ function renderSummaryTab() { const el = document.getElementById(`${NS}-summary-body`); if (!el) return; const total = computeTotalSummary(); const promoterData = computePromoterSummary(); const dailyData = computeDailySummary(); // 总体汇总卡片 const totalCards = [ { k: '推广员数', v: fmtNum(total.promoterCount) }, { k: '注册记录', v: fmtNum(total.regCount) }, { k: '注册人数', v: fmtNum(total.regPeopleCount) }, { k: '充值记录', v: fmtNum(total.payCount) }, { k: '充值金额', v: fmtNum(total.payAmount, 2) }, { k: '付费人数', v: fmtNum(total.payPeopleCount) }, { k: '人均ARPU', v: fmtNum(total.arpu, 2) }, { k: '付费转化', v: total.conversionRate.toFixed(1) + '%' }, ]; // 推广员排行表格 const promoterRows = Array.from(promoterData.entries()) .sort((a, b) => b[1].payAmount - a[1].payAmount) .map(([name, r]) => ` ${esc(name)} ${fmtNum(r.regCount)} ${fmtNum(r.regPeopleCount)} ${fmtNum(r.payCount)} ${fmtNum(r.payAmount, 2)} ${fmtNum(r.payPeopleCount)} ${fmtNum(r.arpu, 2)} ${r.conversionRate.toFixed(1)}% `).join(''); // 日趋势表格 const dailyRows = dailyData.slice(0, 30).map(r => ` ${esc(r.day)} ${fmtNum(r.regCount)} ${fmtNum(r.payCount)} ${fmtNum(r.payAmount, 2)} ${fmtNum(r.arpu, 2)} `).join(''); el.innerHTML = `

📊 总体概览

${totalCards.map(c => `
${esc(c.k)}${esc(c.v)}
`).join('')}

👥 推广员排行 按充值金额排序

${promoterRows ? `
${promoterRows}
推广员 注册数 注册人数 充值笔数 充值金额 付费人数 人均ARPU 转化率
` : '
暂无数据,请先采集注册和充值数据
'}

📅 日趋势 近30天

${dailyRows ? `
${dailyRows}
日期 注册 充值笔数 充值金额 ARPU
` : '
暂无数据
'}
💡 提示:智能汇总基于本地存储的注册和充值数据计算。
个人后台采集的数据无推广员字段,将显示为「全部数据」
总后台采集的数据包含推广员字段(Flame842~851等),可按推广员分开统计
• 点击顶栏「✏ 别名」可设置推广员显示名称(如 Flame842=阿雄)
`; } /** 完整渲染面板(首次打开时调用) */ function renderPanel() { const panel = document.getElementById(panelId); if (!panel) return; const isFirst = !panel.querySelector('.hgxd-shell'); if (isFirst) buildPanelShell(); applyFiltersAndSort(); renderTopbar(); renderTabbar(); renderFilterbar(); renderCards(); renderInsights(); renderTable(); renderPager(); // 根据当前标签切换显示 switchTab(); } // ───────────────────────────────────────────── // 事件绑定(使用事件委托,只绑一次) // ───────────────────────────────────────────── function bindPanelEvents(panel) { // ── 点击委托 ── panel.addEventListener('click', e => { const t = e.target instanceof Element ? e.target : null; if (!t) return; const id = t.id; // 页面导航 if (t.classList.contains('hgxd-nav-btn') && t.dataset.page) { location.href = buildNavUrl(t.dataset.page); return; } // 渠道切换 if (t.classList.contains('hgxd-ch-btn') && t.dataset.channel) { const url = new URL(location.href); url.searchParams.set('type', t.dataset.channel); url.searchParams.delete('p'); location.href = url.toString(); return; } // 标签切换 if (t.classList.contains('hgxd-tab') && t.dataset.tab) { uiState.activeTab = t.dataset.tab; renderTabbar(); switchTab(); return; } switch (id) { case `${NS}-layout`: cycleLayout(); break; case `${NS}-close`: saveCurrentRect(); panel.style.display = 'none'; uiState.panelOpen = false; break; case `${NS}-refresh`: if (!extractTableData()) { alert('未读取到表格数据,请先执行页面查询。'); return; } renderPanel(); collectAllPages(); break; case `${NS}-apply`: { uiState.search = normalizeText(panel.querySelector(`#${NS}-search`)?.value || ''); uiState.days = panel.querySelector(`#${NS}-days`)?.value || 'all'; const ps = Number(panel.querySelector(`#${NS}-page-size`)?.value || 50); uiState.pageSize = Number.isFinite(ps) && ps > 0 ? ps : 50; uiState.page = 1; renderDataArea(); break; } case `${NS}-reset`: uiState.search = ''; uiState.days = 'all'; uiState.pageSize = 50; uiState.page = 1; uiState.sort = { ...uiState.sort, desc: true }; dataState.headers.forEach(h => { dataState.columnVisible[h] = true; }); renderPanel(); break; case `${NS}-csv`: exportCsv(); break; case `${NS}-collect`: collectAllPages(); break; case `${NS}-collect-cancel`: if (collectState._abort) collectState._abort(); break; case `${NS}-prev`: if (uiState.page > 1) { uiState.page--; renderTable(); renderPager(); } break; case `${NS}-next`: { const tp = Math.max(1, Math.ceil(dataState.filteredRows.length / uiState.pageSize)); if (uiState.page < tp) { uiState.page++; renderTable(); renderPager(); } break; } case `${NS}-cols-btn`: { e.stopPropagation(); const drop = panel.querySelector(`#${NS}-cols-drop`); if (drop) drop.style.display = drop.style.display === 'none' ? 'block' : 'none'; break; } case `${NS}-alias-editor`: openAliasEditor(); break; case `${NS}-clear-persist`: if (confirm('确定要清除本地存储的所有注册/充值数据吗?')) { clearPersistData(); renderTabbar(); renderSummaryTab(); } break; default: // 排序表头 if (t.classList.contains('hgxd-sort-th')) { const i = Number(t.getAttribute('data-idx')); if (Number.isFinite(i)) { if (uiState.sort.idx === i) uiState.sort.desc = !uiState.sort.desc; else { uiState.sort.idx = i; uiState.sort.desc = true; } uiState.page = 1; renderTable(); renderPager(); } } } }); // ── 复选框变化 ── panel.addEventListener('change', e => { const t = e.target; if (t instanceof HTMLInputElement && t.type === 'checkbox' && t.hasAttribute('data-col')) { const col = t.getAttribute('data-col'); if (col) { dataState.columnVisible[col] = t.checked; uiState.page = 1; renderTable(); } } }); // ── 键盘 ── panel.addEventListener('keydown', e => { const t = e.target; if (!(t instanceof HTMLInputElement)) return; if (t.id === `${NS}-search`) { if (e.key === 'Enter') { e.preventDefault(); panel.querySelector(`#${NS}-apply`)?.click(); } if (e.key === 'Escape') { t.value = ''; t.blur(); } } if (t.id === `${NS}-jump` && e.key === 'Enter') { e.preventDefault(); const tp = Math.max(1, Math.ceil(dataState.filteredRows.length / uiState.pageSize)); const v = Math.floor(Number(t.value)); if (Number.isFinite(v) && v >= 1 && v <= tp) { uiState.page = v; renderTable(); renderPager(); } else { t.value = String(uiState.page); t.select(); } } }); panel.addEventListener('focusout', e => { const t = e.target; if (t instanceof HTMLInputElement && t.id === `${NS}-jump`) t.value = String(uiState.page); }); // ── 全局点击关闭下拉 ── document.addEventListener('click', () => { const drop = panel.querySelector(`#${NS}-cols-drop`); if (drop) drop.style.display = 'none'; }); // ── 拖拽 ── panel.addEventListener('pointerdown', e => { if (uiState.layout === 'full' || e.button !== 0) return; const handle = e.target instanceof Element && e.target.closest('.hgxd-drag-handle'); if (!handle || !panel.contains(handle)) return; const rect = panel.getBoundingClientRect(); const ox = e.clientX - rect.left, oy = e.clientY - rect.top; uiState.dragging = true; panel.classList.add('hgxd-dragging'); handle.setPointerCapture?.(e.pointerId); const onMove = ev => { if (!uiState.dragging) return; applyRect({ left: ev.clientX-ox, top: ev.clientY-oy, width: rect.width, height: rect.height }); }; const onUp = () => { uiState.dragging = false; panel.classList.remove('hgxd-dragging'); saveCurrentRect(); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); }); } // ───────────────────────────────────────────── // 推广员别名编辑器 // ───────────────────────────────────────────── function openAliasEditor() { const existing = document.getElementById(`${NS}-alias-modal`); if (existing) { existing.remove(); return; } const lines = Object.entries(promoterAlias).map(([k,v]) => `${k}=${v}`).join('\n'); const modal = document.createElement('div'); modal.id = `${NS}-alias-modal`; modal.className = 'hgxd-modal-overlay'; modal.innerHTML = `
✏ 编辑推广员别名

每行一条,格式:原始名=显示名
示例:Flame842=阿雄

`; document.body.appendChild(modal); const close = () => modal.remove(); modal.querySelector(`#${NS}-alias-close`).addEventListener('click', close); modal.querySelector(`#${NS}-alias-cancel`).addEventListener('click', close); modal.addEventListener('click', e => { if (e.target === modal) close(); }); modal.querySelector(`#${NS}-alias-save`).addEventListener('click', () => { const text = modal.querySelector(`#${NS}-alias-text`).value; const newAlias = {}; text.split('\n').forEach(line => { const [k, ...rest] = line.split('='); const kk = k?.trim(), vv = rest.join('=').trim(); if (kk && vv) newAlias[kk] = vv; }); promoterAlias = newAlias; saveAlias(); // 重新应用别名到已有数据 applyPromoterAlias(dataState.rows); renderDataArea(); close(); }); } // ───────────────────────────────────────────── // 面板开启 // ───────────────────────────────────────────── function showPanel() { const panel = document.getElementById(panelId); if (!panel) return; if (!extractTableData()) { alert('当前页面没有可用表格数据,请先筛选查询后再打开看板。'); return; } panel.style.display = 'block'; uiState.panelOpen = true; applyPanelLayout(true); renderPanel(); collectAllPages(); } // ───────────────────────────────────────────── // 样式注入 // ───────────────────────────────────────────── function injectStyle() { if (document.getElementById(styleId)) return; const s = document.createElement('style'); s.id = styleId; s.textContent = ` /* ── 触发按钮 ── */ #${btnId}{ position:fixed;right:20px;bottom:20px;z-index:2147483647; border:none;border-radius:12px;padding:11px 18px; background:linear-gradient(135deg,#009def,#0070c0);color:#fff; font-size:14px;font-weight:600;cursor:pointer;letter-spacing:.3px; box-shadow:0 4px 20px rgba(0,157,239,.45);transition:all .25s;user-select:none } #${btnId}:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(0,157,239,.6)} #${btnId}:active{transform:translateY(0)} /* ── 面板容器 ── */ #${panelId}{ position:fixed;z-index:999998; background:#f0f4f8;border:none;border-radius:16px; box-shadow:0 20px 60px rgba(0,0,0,.22); display:none;overflow:hidden;padding:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; color:#1a2d3d; resize:both;min-width:620px;min-height:420px; max-width:calc(100vw - 8px);max-height:calc(100vh - 8px) } #${panelId}.hgxd-layout-full{ left:0!important;top:0!important;width:100vw!important;height:100vh!important; border-radius:0;resize:none } #${panelId}.hgxd-dragging{opacity:.95;cursor:move} .hgxd-shell{display:flex;flex-direction:column;height:100%;overflow:hidden} /* ── 顶栏 ── */ .hgxd-topbar{ flex-shrink:0;display:flex;justify-content:space-between;align-items:center; background:linear-gradient(135deg,#1a3a5c,#0f2744);padding:12px 16px;gap:12px } .hgxd-topbar-l{display:flex;align-items:center;gap:10px;flex:1;cursor:move;user-select:none;transition:opacity .2s} .hgxd-topbar-l:hover{opacity:.85} #${panelId}.hgxd-layout-full .hgxd-topbar-l{cursor:default} #${panelId}.hgxd-layout-full .hgxd-topbar-l:hover{opacity:1} .hgxd-fire{font-size:20px;line-height:1} .hgxd-title{color:#fff;font-size:15px;font-weight:700;line-height:1.3} .hgxd-subtitle{color:#7fb8d9;font-size:11px;margin-top:1px} .hgxd-topbar-r{display:flex;align-items:center;gap:6px;flex-shrink:0} .hgxd-page-nav{display:inline-flex;gap:2px;background:rgba(0,0,0,.2);border-radius:7px;padding:2px;margin-right:6px} .hgxd-nav-btn{ background:transparent;border:none;color:#93b8d7;border-radius:5px; padding:4px 10px;font-size:11px;cursor:pointer;transition:all .2s;white-space:nowrap } .hgxd-nav-btn:hover{background:rgba(255,255,255,.12);color:#d0e8f8} .hgxd-nav-btn.active{background:rgba(255,255,255,.2);color:#fff;font-weight:600} .hgxd-ch-sep{color:rgba(255,255,255,.25);font-size:14px;line-height:26px;margin:0 2px;user-select:none} .hgxd-ch-btn{font-size:10px!important;padding:3px 7px!important} .hgxd-tbtn{ background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2); color:#c8dff0;border-radius:7px;padding:5px 11px;font-size:12px;cursor:pointer; transition:background .2s;white-space:nowrap } .hgxd-tbtn:hover{background:rgba(255,255,255,.22);color:#fff} .hgxd-tbtn-close{ background:transparent;border:1px solid rgba(231,76,60,.4); color:#ff9b91;border-radius:7px;padding:5px 11px;font-size:13px; font-weight:700;cursor:pointer;transition:all .2s } .hgxd-tbtn-close:hover{background:rgba(231,76,60,.3);color:#fff;border-color:rgba(231,76,60,.7)} /* ── 标签栏 ── */ .hgxd-tabbar{ flex-shrink:0;display:flex;justify-content:space-between;align-items:center; background:#fff;border-bottom:1px solid #e5e7eb;padding:8px 16px;gap:12px } .hgxd-tabs{display:flex;gap:4px} .hgxd-tab{ background:transparent;border:none;color:#6b7280;border-radius:6px; padding:6px 14px;font-size:13px;cursor:pointer;transition:all .2s;font-weight:500 } .hgxd-tab:hover{background:#f3f4f6;color:#374151} .hgxd-tab.active{background:#e0f2fe;color:#0284c7;font-weight:600} .hgxd-tab-actions{display:flex;align-items:center;gap:8px} .hgxd-tab-info{font-size:11px;color:#6b7280;background:#f3f4f6;padding:4px 10px;border-radius:4px} .hgxd-tab-info-empty{color:#9ca3af;font-style:italic} .hgxd-btn-small{padding:4px 8px!important;font-size:11px!important} /* ── 筛选栏 ── */ .hgxd-filterbar{ flex-shrink:0;display:flex;flex-wrap:wrap;gap:10px;align-items:center; background:#fff;border-bottom:1px solid #dde4ed;padding:14px 18px } .hgxd-filterbar input,.hgxd-filterbar select{ border:1px solid #d0dbe8;border-radius:7px;padding:7px 12px;font-size:13px; background:#f8fafc;color:#1a2d3d;outline:none;transition:border-color .2s } .hgxd-filterbar input:focus,.hgxd-filterbar select:focus{border-color:#009def;background:#fff} .hgxd-filterbar input{min-width:240px} .hgxd-filterbar-sep{width:1px;height:24px;background:#e5edf5;margin:0 6px;flex-shrink:0} .hgxd-btn-primary{ background:linear-gradient(135deg,#009def,#0070c0);color:#fff; border:none;border-radius:7px;padding:7px 16px;font-size:13px; cursor:pointer;font-weight:600;transition:filter .2s;white-space:nowrap } .hgxd-btn-primary:hover{filter:brightness(1.12)} .hgxd-btn-ghost{ background:#f0f4f8;color:#2c4a6e;border:1px solid #d0dbe8;border-radius:7px; padding:6px 14px;font-size:13px;cursor:pointer;transition:all .2s;white-space:nowrap } .hgxd-btn-ghost:hover{background:#e2ecf7;border-color:#009def;color:#0070c0} .hgxd-btn-danger{border-color:#f87171!important;color:#dc2626!important} .hgxd-btn-danger:hover{background:#fee2e2!important;border-color:#dc2626!important} .hgxd-meta{ display:inline-flex;align-items:center;gap:4px; background:#fff8e6;border:1px dashed #f5c542;border-radius:7px; padding:6px 12px;font-size:12px;color:#7c5700;margin-left:4px } /* ── 列显示下拉 ── */ .hgxd-cols{position:relative;display:inline-flex} .hgxd-cols-drop{ position:absolute;right:0;top:calc(100% + 4px);z-index:10; width:220px;max-height:280px;overflow-y:auto; background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:6px 0; box-shadow:0 6px 16px rgba(0,0,0,.12) } .hgxd-cols-drop::-webkit-scrollbar{width:5px} .hgxd-cols-drop::-webkit-scrollbar-thumb{background:#c8d4e1;border-radius:3px} #${panelId} .hgxd-cols-drop label{ display:flex!important;align-items:center!important;gap:8px!important;font-size:13px!important; padding:6px 12px!important;color:#374151!important;cursor:pointer!important; transition:background .15s!important;user-select:none!important; margin:0!important;line-height:1.4!important;background:transparent!important; border:none!important;box-shadow:none!important;width:100%!important;box-sizing:border-box!important } #${panelId} .hgxd-cols-drop label:hover{background:#f3f4f6!important} #${panelId} .hgxd-cols-drop input[type="checkbox"]{ -webkit-appearance:none!important;appearance:none!important; width:16px!important;height:16px!important;min-width:16px!important; margin:0!important;padding:0!important; border:1.5px solid #d1d5db!important;border-radius:3px!important; background:#fff!important;cursor:pointer!important;transition:all .15s!important; position:relative!important;flex-shrink:0!important; outline:none!important;display:inline-block!important;vertical-align:middle!important } #${panelId} .hgxd-cols-drop input[type="checkbox"]:checked{background:#3b82f6!important;border-color:#3b82f6!important} #${panelId} .hgxd-cols-drop input[type="checkbox"]:checked::after{ content:''!important;position:absolute!important;left:4px!important;top:1px!important; width:5px!important;height:9px!important;border:solid #fff!important; border-width:0 2px 2px 0!important;transform:rotate(45deg)!important;display:block!important } /* ── 主体 ── */ .hgxd-body{flex:1;overflow-y:auto;padding:14px;min-height:0} .hgxd-cards{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px} .hgxd-card{ background:#fff;border-radius:20px;padding:10px 18px; box-shadow:0 2px 8px rgba(0,0,0,.06);border-left:4px solid #009def; transition:transform .2s,box-shadow .2s;display:inline-flex;align-items:center;gap:12px; min-width:fit-content;flex:0 0 auto } .hgxd-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.1)} .hgxd-card span{font-size:12px;color:#7a95b0;font-weight:500;white-space:nowrap} .hgxd-card b{font-size:20px;font-weight:700;color:#1a2d3d;white-space:nowrap} .hgxd-card:nth-child(1){border-left-color:#009def} .hgxd-card:nth-child(2){border-left-color:#27ae60} .hgxd-card:nth-child(3){border-left-color:#e67e22} .hgxd-card:nth-child(4){border-left-color:#8e44ad} .hgxd-card:nth-child(5){border-left-color:#e74c3c} .hgxd-card:nth-child(6){border-left-color:#16a085} /* ── 洞察区 ── */ .hgxd-insights{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:10px} .hgxd-insights section{background:#fff;border-radius:8px;padding:10px 12px;border:1px solid #e5e7eb} .hgxd-insights h4{margin:0 0 6px;font-size:12px;font-weight:600;color:#374151;display:flex;align-items:center;gap:6px} .hgxd-insights h4::before{content:'';display:block;width:3px;height:12px;border-radius:2px;background:#3b82f6;flex-shrink:0} .hgxd-insights section:nth-child(2) h4::before{background:#f59e0b} .hgxd-insights section:nth-child(3) h4::before{background:#8b5cf6} .hgxd-empty{font-size:12px;color:#a0b4c8;padding:24px 16px;text-align:center;background:#f8fafc;border-radius:8px;border:1px dashed #d0dbe8} .hgxd-empty::before{content:'📊';display:block;font-size:32px;margin-bottom:8px;opacity:.5} /* ── 柱状图 ── */ .hgxd-bar-row{display:grid;grid-template-columns:100px 1fr 60px;gap:8px;align-items:center;margin:5px 0} .hgxd-name{font-size:12px;color:#2c4a6e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .hgxd-track{height:7px;background:#eef3f8;border-radius:99px;overflow:hidden} .hgxd-track > span{display:block;height:100%;border-radius:99px;transition:width .5s ease} .hgxd-val{font-size:12px;color:#4a6480;font-weight:600;text-align:right} /* ── 迷你表格 ── */ .hgxd-mini-table{width:100%;border-collapse:collapse;font-size:11px} .hgxd-mini-table thead{background:#f8f9fa} .hgxd-mini-table th{padding:5px 8px;text-align:left;font-weight:600;color:#4b5563;border-bottom:1px solid #e5e7eb} .hgxd-mini-table td{padding:4px 8px;border-bottom:1px solid #f3f4f6;color:#374151} .hgxd-mini-table tr:last-child td{border-bottom:none} .hgxd-mini-table tr:hover td{background:#f0f7ff} .hgxd-mini-wrap{max-height:180px;overflow:auto;border:1px solid #e5e7eb;border-radius:6px} .hgxd-mini-wrap::-webkit-scrollbar{width:4px;height:4px} .hgxd-mini-wrap::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:2px} .hgxd-mini-table-compact thead th{position:sticky;top:0;z-index:1;background:#f8f9fa} /* ── 统计卡片(登录汇总) ── */ .hgxd-stat-card{background:#fafbfc;border:1px solid #e5e7eb;border-radius:6px;padding:8px 10px} .hgxd-stat-head{display:flex;align-items:center;gap:5px;margin-bottom:6px} .hgxd-stat-title{font-size:13px;font-weight:500;color:#374151;line-height:1.3} .hgxd-stat-qmark{display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;border-radius:50%;font-size:9px;color:#9ca3af;border:1px solid #d1d5db;background:transparent;cursor:help;line-height:1} .hgxd-stat-cols{display:grid;grid-template-columns:1fr 1fr;gap:6px} .hgxd-stat-col{min-width:0} .hgxd-stat-label{font-size:10px;color:#6b7280;margin:0 0 2px;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .hgxd-stat-value{font-size:18px;line-height:1.1;font-weight:600;color:#111827;margin:0 0 3px} .hgxd-stat-compare{display:flex;align-items:center;gap:4px} .hgxd-stat-compare>span{font-size:11px;color:#6b7280;font-weight:500} .hgxd-stat-trend{display:inline-flex;align-items:center;gap:1px;font-size:11px;font-weight:700} .hgxd-stat-trend.up{color:#16a34a} .hgxd-stat-trend.down{color:#ef4444} .hgxd-stat-trend.flat{color:#6b7280} /* ── 主表格 ── */ .hgxd-table-scroll{overflow:auto;border-radius:10px;border:1px solid #e0e8f0;margin-bottom:10px;max-height:calc(100vh - 520px);min-height:300px} .hgxd-table-scroll::-webkit-scrollbar{width:10px;height:10px} .hgxd-table-scroll::-webkit-scrollbar-thumb{background:#b0c4d8;border-radius:5px;border:2px solid #f0f4f8} .hgxd-table-scroll::-webkit-scrollbar-thumb:hover{background:#8ea9c4} .hgxd-table-scroll::-webkit-scrollbar-track{background:#f0f4f8;border-radius:5px} .hgxd-table{border-collapse:collapse;min-width:100%;width:max-content;font-size:12px} .hgxd-table thead tr{background:linear-gradient(135deg,#1a3a5c,#0f2744)} .hgxd-table thead th{ padding:10px 14px;text-align:left;white-space:nowrap; color:#b8d4e8;font-weight:600;font-size:12px;border:none; position:sticky;top:0;z-index:2;min-width:80px } .hgxd-sort-th{cursor:pointer;user-select:none;transition:color .15s} .hgxd-sort-th:hover{color:#fff} .hgxd-table tbody tr{border-bottom:1px solid #edf2f7;transition:background .15s} .hgxd-table tbody tr:nth-child(even){background:#f8fbff} .hgxd-table tbody tr:hover{background:#dff0fd} .hgxd-table tbody td{padding:8px 14px;color:#1a2d3d;white-space:nowrap} .hgxd-amount-high{color:#c0392b;font-weight:700} /* ── 智能汇总页面 ── */ .hgxd-summary-body{flex:1;overflow-y:auto;padding:16px;min-height:0} .hgxd-summary-section{margin-bottom:20px} .hgxd-summary-section h3{margin:0 0 12px;font-size:14px;font-weight:600;color:#1a2d3d;display:flex;align-items:center;gap:8px} .hgxd-summary-section h3 small{font-size:11px;font-weight:400;color:#6b7280} .hgxd-table-scroll-sm{max-height:400px} .hgxd-table tbody td.hgxd-amount{color:#c0392b;font-weight:600} .hgxd-summary-tip{ font-size:12px;color:#6b7280;background:#fef3c7;border:1px solid #fcd34d; border-radius:8px;padding:10px 14px;margin-top:12px;line-height:1.6 } /* ── 分页器 ── */ .hgxd-pager{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 2px} .hgxd-pager-btn{background:#fff;border:1px solid #d0dbe8;border-radius:7px;padding:6px 14px;font-size:13px;cursor:pointer;color:#2c4a6e;font-weight:500;transition:all .2s} .hgxd-pager-btn:hover:not(:disabled){background:#009def;color:#fff;border-color:#009def} .hgxd-pager-btn:disabled{opacity:.4;cursor:not-allowed} .hgxd-pager-info{font-size:13px;color:#7a95b0} .hgxd-pager-jump{display:inline-flex;align-items:center;gap:5px;font-size:13px;color:#7a95b0} .hgxd-pager-jump input{width:52px;border:1px solid #d0dbe8;border-radius:6px;padding:4px 8px;text-align:center;font-size:13px;outline:none} /* ── 采集状态 ── */ .hgxd-collect-progress{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:#fffbe6;border:1px dashed #faad14;border-radius:7px;font-size:12px;color:#7c5700;animation:hgxd-blink 1.2s infinite} .hgxd-collect-done{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:#f6ffed;border:1px solid #95de64;border-radius:7px;font-size:12px;color:#237804} /* ── 别名编辑模态框 ── */ .hgxd-modal-overlay{ position:fixed;inset:0;z-index:9999999; background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center } .hgxd-modal{ background:#fff;border-radius:12px;padding:20px;width:360px; box-shadow:0 20px 60px rgba(0,0,0,.3) } .hgxd-modal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-weight:600;font-size:14px;color:#1a2d3d} .hgxd-modal-tip{font-size:12px;color:#6b7280;margin:0 0 10px;line-height:1.6} .hgxd-modal textarea{width:100%;box-sizing:border-box;border:1px solid #d1d5db;border-radius:8px;padding:8px;font-size:13px;font-family:monospace;resize:vertical;outline:none} .hgxd-modal textarea:focus{border-color:#009def} .hgxd-modal-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:12px} /* ── 动画 ── */ @keyframes hgxd-blink{0%,100%{opacity:1}50%{opacity:.5}} @keyframes hgxd-fadein{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} .hgxd-shell{animation:hgxd-fadein .3s ease-out} /* ── 响应式 ── */ @media(max-width:1280px){.hgxd-insights{grid-template-columns:1fr 1fr}} @media(max-width:1024px){ #${panelId}{left:0;top:0;width:100vw!important;height:100vh!important;border-radius:0;resize:none} .hgxd-filterbar{gap:8px;padding:12px 14px} .hgxd-filterbar input{min-width:160px;font-size:12px} } @media(max-width:768px){ #${btnId}{right:12px;bottom:12px;padding:9px 14px;font-size:13px} .hgxd-cards{gap:8px} .hgxd-card{padding:8px 14px;gap:8px} .hgxd-card b{font-size:16px} .hgxd-filterbar{flex-direction:column;align-items:stretch} .hgxd-filterbar input,.hgxd-filterbar select,.hgxd-filterbar button{width:100%;min-width:auto} } `; document.head.appendChild(s); } // ───────────────────────────────────────────── // 挂载 // ───────────────────────────────────────────── function mount() { if (document.getElementById(btnId)) return; log.info(`脚本启动 v3.1.0 | URL: ${location.href}`); log.info(`后台类型: ${isAdminBackend() ? '总后台' : '个人后台'} | 渠道: ${getChannelType()}`); loadLayoutPrefs(); injectStyle(); // 触发按钮 const btn = Object.assign(document.createElement('button'), { id: btnId, type: 'button', textContent: '数据看板 Pro', }); btn.addEventListener('click', showPanel); // 面板容器 const panel = Object.assign(document.createElement('div'), { id: panelId }); document.body.appendChild(btn); document.body.appendChild(panel); applyPanelLayout(true); // 面板 resize → 保存位置(防抖 300ms) if ('ResizeObserver' in window) { const ro = new ResizeObserver(debounce(() => { if (!uiState.dragging && uiState.layout !== 'full') saveCurrentRect(); }, 300)); ro.observe(panel); } window.addEventListener('resize', debounce(() => { if (panel.style.display !== 'none') applyPanelLayout(true); }, 150)); } function shouldRun() { if (!/union\.firsoo\.com/i.test(location.href)) return false; if (/[?&]ac=(qudao_pay|qudao_reg|qudao_d_pay|qudao_d_reg|zonglan|huizong)\b/.test(location.href)) return true; if (/[?&]ct=account\b/.test(location.href) && document.querySelector('table.layui-table')) return true; return false; } if (shouldRun()) mount(); })();