// ==UserScript== // @name Corner Crypto Ticker Pro // @name:en Corner Crypto Ticker Pro // @name:ja Corner Crypto Ticker Pro // @name:ko Corner Crypto Ticker Pro // @namespace https://tampermonkey.net/ // @version 2.4.1 // @description 在任意网页角落悬浮显示加密资产行情:实时拉取 BTC/ETH/DOGE 等自定义币种价格与涨跌(OKX/Binance/Coinbase Exchange 可选),支持可拖拽(可设为按Shift)、可调整大小、点击直达交易所对应行情页、自动失败切换数据源、配置面板自定义(数据源/币种/涨跌显示与颜色/单位符号/涨跌基准/刷新频率/默认位置尺寸)并支持导入导出;支持币种 Logo 自动抓取(可被自定义 logo 覆盖)。 // @description:en A lightweight, customizable, draggable corner crypto ticker for any webpage. Real-time prices & daily change for BTC/ETH/DOGE and custom symbols via OKX/Binance/Coinbase Exchange. Supports drag (optional Shift), resize, click-through to exchange market pages, automatic data-source fallback, and an in-script configuration panel with import/export. Includes best-effort auto logo fetching (overridable by custom logo URL). // @description:ja あらゆるWebページの隅に暗号資産の価格を表示する軽量ティッカーです。OKX/Binance/Coinbase Exchange の公開APIから BTC/ETH/DOGE など任意の銘柄の価格と騰落を取得。ドラッグ移動(任意でShift必須)、サイズ変更、取引所の該当マーケットへのクリック遷移、自動フォールバック、設定パネル(インポート/エクスポート)に対応。ロゴも可能な範囲で自動取得(URL指定で上書き可)。 // @description:ko 모든 웹페이지 구석에 암호화폐 시세를 띄워주는 가벼운 티커입니다. OKX/Binance/Coinbase Exchange 공개 API로 BTC/ETH/DOGE 및 사용자 지정 코인의 가격과 등락을 표시합니다. 드래그 이동(선택적으로 Shift 필요), 크기 조절, 거래소 시세 페이지로 클릭 이동, 자동 데이터 소스 fallback, 설정 패널(가져오기/내보내기) 지원. 코인 로고 자동 로드(가능한 범위, 사용자 로고 URL로 덮어쓰기 가능). // @icon https://youke2.picui.cn/s1/2025/12/21/694744b22531b.png // @author BFD_qt // @license MIT // @match *://*/* // @run-at document-end // @noframes // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_openInTab // @connect www.okx.com // @connect api.binance.com // @connect api.exchange.coinbase.com // @connect raw.githubusercontent.com // @downloadURL https://update.greasyfork.icu/scripts/559648/Corner%20Crypto%20Ticker%20Pro.user.js // @updateURL https://update.greasyfork.icu/scripts/559648/Corner%20Crypto%20Ticker%20Pro.meta.js // ==/UserScript== /** * Corner Crypto Ticker Pro * ======================= * Maintainer Notes * - UI: DOM + CSS via GM_addStyle, no external deps. Rebuild on apply to avoid partial state drift. * - Network: GM_xmlhttpRequest avoids CORS. Extending exchanges requires adding @connect. * - Storage: Settings JSON stored under STORE_KEY. Bump STORE_KEY if schema changes. * - Auto Logo: best-effort from a public icon repo; user-specified logo always takes precedence. * * Compliance / Risk Notice * ------------------------ * 本脚本仅用于展示公开行情信息与便捷跳转至公开行情页面,不提供交易、撮合、下单、资金划转、充值/提现等任何功能, * 不构成投资建议,也不对任何数字资产/平台/服务作推荐。 * * 数字资产相关活动在不同司法辖区的监管政策差异较大。以中国大陆为例,监管部门已发布多份文件/通知, * 对“虚拟货币相关业务活动”等作出严格限制,并明确相关活动属于非法金融活动的范围。用户应自行了解并遵守所在地区 * 法律法规及监管要求,仅将本脚本用于信息查询与学习交流用途。严禁利用本脚本从事任何违法违规的数字资产交易、 * 募集、支付结算、宣传推广或其他相关活动;因用户自行使用产生的风险与后果由用户自行承担。 */ (function () { "use strict"; // --------------------------------------------------------------------------- // Storage & defaults // --------------------------------------------------------------------------- const STORE_KEY = "CCTP_SETTINGS_V2_4_1"; const DEFAULT_SETTINGS = { exchange: "OKX", // OKX | BINANCE | COINBASE_EXCHANGE autoFallback: { enabled: true, order: ["OKX", "BINANCE", "COINBASE_EXCHANGE"], threshold: 3, cooldownMs: 60_000, toastMs: 2200, }, refreshMs: 5000, position: { mode: "custom", top: 774, left: 3 }, size: { width: 216, height: 151 }, drag: { requireShift: true }, // Auto logo (best-effort). If coin.logo is provided, it overrides auto fetch. autoLogo: { enabled: true, // Source: spothq cryptocurrency-icons on GitHub (PNG). Not all symbols exist. // Fallback behavior: onerror -> show letter badge. source: "spothq", // Prefer icon size path. Common options in that repo: 32, 64, 128 (folder names may vary by branch). // This script uses a stable path: 64/color/.png size: 64, }, quote: "USDT", unitSymbol: { USDT: "$", USD: "$", USDC: "$", CNY: "¥", EUR: "€", JPY: "¥", BTC: "BTC", ETH: "ETH", }, showPct: true, showAbs: false, decimals: { ge1: 4, ge1000: 2, lt1: 8 }, changeBase: { mode: "OKX_sodUtc8", label: "今日(UTC+8)" }, colors: { up: "#16c784", down: "#ea3943", flat: "#9aa4b2", text: "#eaecef", bg: "rgba(17, 24, 39, 0.88)", border: "rgba(255,255,255,0.12)", }, coins: [ { base: "BTC", quote: "USDT", label: "BTC", logo: "" }, { base: "ETH", quote: "USDT", label: "ETH", logo: "" }, { base: "DOGE", quote: "USDT", label: "DOGE", logo: "" }, ], ui: { compact: true, fontSize: 11, headerPadY: 5, headerPadX: 7, bodyPadY: 6, bodyPadX: 7, rowPadY: 4, rowPadX: 6, gap: 5, borderRadius: 10, buttonPadY: 2, buttonPadX: 6, }, }; function deepMerge(a, b) { if (!b) return a; const out = Array.isArray(a) ? [...a] : { ...a }; for (const k of Object.keys(b)) { if ( b[k] && typeof b[k] === "object" && !Array.isArray(b[k]) && a && typeof a[k] === "object" && !Array.isArray(a[k]) ) out[k] = deepMerge(a[k], b[k]); else out[k] = b[k]; } return out; } function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } function toNum(x) { const n = Number(x); return Number.isFinite(n) ? n : NaN; } function safeJsonParse(s) { try { return JSON.parse(s); } catch { return null; } } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } const now = () => Date.now(); async function loadSettings() { const raw = await GM_getValue(STORE_KEY, ""); const parsed = raw ? safeJsonParse(raw) : null; return deepMerge(DEFAULT_SETTINGS, parsed || {}); } async function saveSettings(s) { await GM_setValue(STORE_KEY, JSON.stringify(s)); } // --------------------------------------------------------------------------- // Network (CORS-safe) // --------------------------------------------------------------------------- function gmGetJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, timeout: 10000, headers: { Accept: "application/json" }, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } }, onerror: () => reject(new Error("network error")), ontimeout: () => reject(new Error("timeout")), }); }); } // --------------------------------------------------------------------------- // Exchanges // fetch() returns: { last, base, pct, abs, ts } // marketUrl() returns click-through URL // --------------------------------------------------------------------------- const EXCHANGES = { OKX: { id: "OKX", name: "OKX", pairId(coin) { return `${coin.base}-${coin.quote}`; }, apiUrl(coin) { return `https://www.okx.com/api/v5/market/ticker?instId=${encodeURIComponent(this.pairId(coin))}`; }, async fetch(coin, settings) { const json = await gmGetJson(this.apiUrl(coin)); if (!json || json.code !== "0" || !json.data?.[0]) throw new Error("OKX bad response"); const t = json.data[0]; const last = toNum(t.last); const ts = toNum(t.ts) || now(); let baseField = "sodUtc8"; if (settings.changeBase.mode === "OKX_sodUtc0") baseField = "sodUtc0"; else if (settings.changeBase.mode === "OKX_open24h") baseField = "open24h"; const base = toNum(t[baseField]); if (!Number.isFinite(last) || !Number.isFinite(base) || base <= 0) throw new Error("OKX missing fields"); return { last, base, pct: ((last - base) / base) * 100, abs: (last - base), ts }; }, marketUrl(coin) { return `https://www.okx.com/trade-spot/${coin.base.toLowerCase()}-${coin.quote.toLowerCase()}`; }, }, BINANCE: { id: "BINANCE", name: "Binance", pairId(coin) { return `${coin.base}${coin.quote}`; }, apiUrl(coin) { return `https://api.binance.com/api/v3/ticker/24hr?symbol=${encodeURIComponent(this.pairId(coin))}`; }, async fetch(coin) { const json = await gmGetJson(this.apiUrl(coin)); const last = toNum(json.lastPrice); const open = toNum(json.openPrice); const abs = toNum(json.priceChange); const pct = toNum(String(json.priceChangePercent).replace("%", "")); if (!Number.isFinite(last) || !Number.isFinite(open) || open <= 0) throw new Error("BINANCE missing fields"); const pct2 = ((last - open) / open) * 100; return { last, base: open, pct: Number.isFinite(pct) ? pct : pct2, abs: Number.isFinite(abs) ? abs : (last - open), ts: now() }; }, marketUrl(coin) { return `https://www.binance.com/en/trade/${coin.base}_${coin.quote}`; }, }, COINBASE_EXCHANGE: { id: "COINBASE_EXCHANGE", name: "Coinbase Exchange", pairId(coin) { return `${coin.base}-${coin.quote}`; }, apiUrlStats(coin) { return `https://api.exchange.coinbase.com/products/${encodeURIComponent(this.pairId(coin))}/stats`; }, async fetch(coin) { const json = await gmGetJson(this.apiUrlStats(coin)); const last = toNum(json.last); const open = toNum(json.open); if (!Number.isFinite(last) || !Number.isFinite(open) || open <= 0) throw new Error("COINBASE missing fields"); return { last, base: open, pct: ((last - open) / open) * 100, abs: (last - open), ts: now() }; }, marketUrl(coin) { return `https://exchange.coinbase.com/trade/${coin.base}-${coin.quote}`; }, }, }; // --------------------------------------------------------------------------- // Auto logo resolver (best-effort) // --------------------------------------------------------------------------- function normalizeSymbolForIcon(symbol) { return String(symbol || "") .trim() .toLowerCase() .replace(/[^a-z0-9]/g, ""); } function resolveAutoLogoUrl(symbol) { // spothq cryptocurrency-icons repo: // https://github.com/spothq/cryptocurrency-icons // PNG path (commonly used): 64/color/.png const sym = normalizeSymbolForIcon(symbol); if (!sym) return ""; return `https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/64/color/${encodeURIComponent(sym)}.png`; } // --------------------------------------------------------------------------- // Formatting // --------------------------------------------------------------------------- function unitPrefix(quote) { return settings.unitSymbol?.[quote] ?? quote; } function formatPrice(n) { if (!Number.isFinite(n)) return "--"; const d = settings.decimals || DEFAULT_SETTINGS.decimals; const digits = n >= 1000 ? d.ge1000 : n >= 1 ? d.ge1 : d.lt1; return n.toLocaleString(undefined, { maximumFractionDigits: digits }); } function formatSigned(n, digits = 2) { if (!Number.isFinite(n)) return "--"; const sign = n > 0 ? "+" : ""; return sign + n.toFixed(digits); } function pickClass(pct) { if (!Number.isFinite(pct) || pct === 0) return "cctp-flat"; return pct > 0 ? "cctp-up" : "cctp-down"; } // --------------------------------------------------------------------------- // UI CSS // --------------------------------------------------------------------------- function buildCss() { const c = settings.colors; const ui = settings.ui || DEFAULT_SETTINGS.ui; const fs = clamp(Number(ui.fontSize) || 11, 9, 14); const hdrPy = clamp(Number(ui.headerPadY) || 5, 2, 10); const hdrPx = clamp(Number(ui.headerPadX) || 7, 4, 14); const bodyPy = clamp(Number(ui.bodyPadY) || 6, 2, 12); const bodyPx = clamp(Number(ui.bodyPadX) || 7, 4, 14); const rowPy = clamp(Number(ui.rowPadY) || 4, 2, 10); const rowPx = clamp(Number(ui.rowPadX) || 6, 3, 14); const gap = clamp(Number(ui.gap) || 5, 3, 10); const br = clamp(Number(ui.borderRadius) || 10, 8, 16); const btnPy = clamp(Number(ui.buttonPadY) || 2, 1, 8); const btnPx = clamp(Number(ui.buttonPadX) || 6, 4, 12); return ` #cctp-box{ position:fixed; z-index:2147483647; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; font-size:${fs}px; line-height:1.25; border-radius:${br}px; color:${c.text}; background:${c.bg}; border:1px solid ${c.border}; box-shadow:0 10px 26px rgba(0,0,0,0.38); overflow:hidden; resize: both; min-width: 170px; min-height: 112px; max-width: min(70vw, 460px); max-height: 70vh; } #cctp-box *{ box-sizing:border-box; } .cctp-up{ color:${c.up}; } .cctp-down{ color:${c.down}; } .cctp-flat{ color:${c.flat}; } .cctp-muted{ opacity:0.75; } #cctp-hdr{ display:flex; align-items:flex-start; justify-content:space-between; gap:${gap + 2}px; padding:${hdrPy}px ${hdrPx}px; cursor: move; user-select:none; border-bottom: 1px solid rgba(255,255,255,0.08); flex-wrap: wrap; } #cctp-title{ display:flex; flex-direction:column; gap: 1px; min-width: 110px; flex: 1 1 auto; max-width: 100%; } #cctp-title .cctp-title-main{ font-weight:900; letter-spacing:0.2px; display:flex; gap: 6px; align-items: baseline; flex-wrap: wrap; } #cctp-subtitle{ font-size:${Math.max(10, fs - 1)}px; opacity:0.78; font-weight:650; white-space: normal; word-break: break-word; } #cctp-btns{ display:flex; gap:${gap}px; align-items:center; flex: 0 0 auto; flex-wrap: wrap; justify-content:flex-end; max-width: 100%; } #cctp-btns button{ cursor:pointer; border:1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); padding:${btnPy}px ${btnPx}px; border-radius:9px; font-size:${Math.max(10, fs - 1)}px; color:inherit; white-space: nowrap; } #cctp-btns button:hover{ background: rgba(255,255,255,0.10); } #cctp-body{ padding:${bodyPy}px ${bodyPx}px ${bodyPy + 1}px; } #cctp-rows{ display:grid; gap:${gap}px; } .cctp-row{ display:grid; grid-template-columns: 20px 40px minmax(0, 1fr) auto; gap:${gap + 1}px; align-items:center; padding:${rowPy}px ${rowPx}px; border-radius:${br - 2}px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); cursor:pointer; min-width:0; } .cctp-row:hover{ border-color: rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); } .cctp-logo{ width:16px; height:16px; border-radius:5px; background: rgba(255,255,255,0.10); display:flex; align-items:center; justify-content:center; overflow:hidden; font-size:${Math.max(9, fs - 2)}px; opacity:0.95; } .cctp-logo img{ width:100%; height:100%; object-fit:contain; display:block; } .cctp-sym{ font-weight:900; } .cctp-price, .cctp-chg{ font-variant-numeric: tabular-nums; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .cctp-chg{ text-align:right; } #cctp-box[data-narrow="1"] .cctp-row{ grid-template-columns: 20px 40px minmax(0, 1fr); grid-template-rows: auto auto; row-gap: 3px; align-items:start; } #cctp-box[data-narrow="1"] .cctp-row .cctp-chg{ grid-column: 2 / 4; justify-self: end; } .cctp-foot{ margin-top:${gap + 1}px; display:flex; justify-content:space-between; gap:${gap + 2}px; font-size:${Math.max(10, fs - 1)}px; opacity:0.72; flex-wrap: wrap; } #cctp-toast{ position:fixed; z-index:2147483647; left:50%; top:14px; transform: translateX(-50%); background: rgba(0,0,0,0.65); color:#fff; padding: 7px 9px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 10px 24px rgba(0,0,0,0.35); font-size:${Math.max(11, fs)}px; display:none; user-select:none; max-width: min(92vw, 560px); white-space: normal; word-break: break-word; } #cctp-box.minimized #cctp-body{ display:none; } /* ===== Config Panel ===== */ #cctp-config-overlay{ position:fixed; inset:0; z-index:2147483647; background: rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; padding: 18px; } #cctp-config{ width: min(980px, 94vw); max-height: 88vh; overflow:auto; border-radius: 16px; background: #0b1220; color: #eaecef; border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 18px 50px rgba(0,0,0,0.5); } #cctp-config .top{ position: sticky; top: 0; display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; padding: 14px 16px; background: #0b1220; border-bottom: 1px solid rgba(255,255,255,0.10); z-index: 2; flex-wrap: wrap; } #cctp-config h2{ margin:0; font-size: 15px; } #cctp-config .hint{ opacity:0.72; font-size:12px; font-weight: 500; } #cctp-config .btns{ display:flex; gap:8px; flex-wrap: wrap; justify-content:flex-end; } #cctp-config button{ cursor:pointer; border:1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); color:#eaecef; padding: 7px 10px; border-radius: 10px; font-size: 12px; white-space: nowrap; } #cctp-config button:hover{ background: rgba(255,255,255,0.10); } #cctp-config .sec{ padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); } #cctp-config .grid{ display:grid; grid-template-columns: 1fr 1fr; gap:12px; align-items:start; } #cctp-config .row2{ display:grid; grid-template-columns: 1fr 1fr; gap:10px; align-items:start; } #cctp-config label{ display:block; font-size: 12px; opacity: 0.86; margin-bottom: 6px; } #cctp-config input, #cctp-config select, #cctp-config textarea{ width: 100%; background: rgba(255,255,255,0.05); color:#eaecef; border: 1px solid rgba(255,255,255,0.14); border-radius: 10px; padding: 8px 10px; font-size: 12px; outline: none; min-width: 0; } #cctp-config textarea{ min-height: 110px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; resize: vertical; } #cctp-config .warn{ margin-top: 8px; font-size: 12px; color: #fbbf24; opacity: 0.95; } #cctp-config .small{ font-size: 12px; opacity: 0.72; margin-top: 6px; } #cctp-config .kbd{ font-family: ui-monospace, monospace; opacity: 0.9; } @media (max-width: 760px){ #cctp-config .grid{ grid-template-columns: 1fr; } #cctp-config .row2{ grid-template-columns: 1fr; } } `; } // --------------------------------------------------------------------------- // Positioning & interaction // --------------------------------------------------------------------------- function applyPosition(el) { el.style.top = ""; el.style.left = ""; el.style.right = ""; el.style.bottom = ""; if (settings.position.mode === "custom") { el.style.top = `${settings.position.top}px`; el.style.left = `${settings.position.left}px`; return; } const pad = 10; const corner = settings.corner || "top-right"; if (corner === "top-left") { el.style.top = `${pad}px`; el.style.left = `${pad}px`; } else if (corner === "bottom-left") { el.style.bottom = `${pad}px`; el.style.left = `${pad}px`; } else if (corner === "bottom-right") { el.style.bottom = `${pad}px`; el.style.right = `${pad}px`; } else { el.style.top = `${pad}px`; el.style.right = `${pad}px`; } } function makeDraggable(box, handle) { let dragging = false; let startX = 0, startY = 0, startTop = 0, startLeft = 0; const requireShift = !!settings.drag?.requireShift; const onDown = (e) => { if (e.target && e.target.tagName === "BUTTON") return; if (requireShift && !e.shiftKey) { showToast("按住 Shift 再拖拽移动窗口"); return; } dragging = true; const rect = box.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; startTop = rect.top; startLeft = rect.left; settings.position.mode = "custom"; settings.position.top = Math.round(rect.top); settings.position.left = Math.round(rect.left); box.style.right = ""; box.style.bottom = ""; box.style.top = `${settings.position.top}px`; box.style.left = `${settings.position.left}px`; document.addEventListener("mousemove", onMove, true); document.addEventListener("mouseup", onUp, true); e.preventDefault(); }; const onMove = (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); const rect = box.getBoundingClientRect(); const w = rect.width; const h = rect.height; const newTop = clamp(startTop + dy, 0, vh - Math.max(40, h)); const newLeft = clamp(startLeft + dx, 0, vw - Math.max(60, w)); box.style.top = `${Math.round(newTop)}px`; box.style.left = `${Math.round(newLeft)}px`; settings.position.top = Math.round(newTop); settings.position.left = Math.round(newLeft); }; const onUp = async () => { if (!dragging) return; dragging = false; document.removeEventListener("mousemove", onMove, true); document.removeEventListener("mouseup", onUp, true); await saveSettings(settings); }; handle.addEventListener("mousedown", onDown, true); } function updateNarrowFlag(box) { // Default width=216 remains data-narrow="0" const w = box.getBoundingClientRect().width; box.dataset.narrow = w < 215 ? "1" : "0"; } function attachResizePersistence(box) { const ro = new ResizeObserver(async () => { const rect = box.getBoundingClientRect(); settings.size.width = Math.round(rect.width); settings.size.height = Math.round(rect.height); await saveSettings(settings); updateNarrowFlag(box); }); ro.observe(box); } // --------------------------------------------------------------------------- // Toast // --------------------------------------------------------------------------- let toastEl = null; let toastTimer = null; function ensureToast() { if (toastEl) return; toastEl = document.createElement("div"); toastEl.id = "cctp-toast"; document.documentElement.appendChild(toastEl); } function showToast(msg, ms) { ensureToast(); toastEl.textContent = msg; toastEl.style.display = "block"; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.style.display = "none"; }, ms ?? settings.autoFallback.toastMs ?? 2000); } // --------------------------------------------------------------------------- // Main card UI // --------------------------------------------------------------------------- function createBox() { GM_addStyle(buildCss()); ensureToast(); const box = document.createElement("div"); box.id = "cctp-box"; box.style.width = `${settings.size.width}px`; box.style.height = `${settings.size.height}px`; applyPosition(box); box.innerHTML = `
行情
${escapeHtml(EXCHANGES[settings.exchange]?.name || settings.exchange)} · ${escapeHtml(settings.changeBase.label || "")}
初始化… --:--:--
`; document.documentElement.appendChild(box); makeDraggable(box, box.querySelector("#cctp-hdr")); attachResizePersistence(box); box.querySelector("#cctp-toggle").addEventListener("click", () => { box.classList.toggle("minimized"); box.querySelector("#cctp-toggle").textContent = box.classList.contains("minimized") ? "展开" : "收起"; }); box.querySelector("#cctp-refresh").addEventListener("click", () => updateAll(true)); updateNarrowFlag(box); return box; } function buildLogoNode(coin) { const wrapper = document.createElement("div"); wrapper.className = "cctp-logo"; // Priority: explicit logo > auto logo > letter badge const explicit = (coin.logo || "").trim(); const autoEnabled = !!settings.autoLogo?.enabled; const autoUrl = (!explicit && autoEnabled) ? resolveAutoLogoUrl(coin.base) : ""; const letter = escapeHtml((coin.base || "?").slice(0, 1).toUpperCase()); if (explicit || autoUrl) { const img = document.createElement("img"); img.alt = (coin.label || coin.base || "").trim(); img.src = explicit || autoUrl; // If auto logo fails, fall back to letter badge img.onerror = () => { wrapper.textContent = letter; }; wrapper.appendChild(img); return wrapper; } wrapper.textContent = letter; return wrapper; } function renderRows(box) { const rows = box.querySelector("#cctp-rows"); rows.innerHTML = ""; for (const coin of settings.coins) { const row = document.createElement("div"); row.className = "cctp-row"; row.dataset.base = coin.base; row.dataset.quote = coin.quote; const logoNode = buildLogoNode(coin); const symNode = document.createElement("div"); symNode.className = "cctp-sym"; symNode.textContent = coin.label || coin.base; const priceNode = document.createElement("div"); priceNode.className = "cctp-price cctp-muted"; priceNode.textContent = "--"; const chgNode = document.createElement("div"); chgNode.className = "cctp-chg cctp-muted"; chgNode.textContent = "--"; row.appendChild(logoNode); row.appendChild(symNode); row.appendChild(priceNode); row.appendChild(chgNode); row.addEventListener("click", () => { const ex = EXCHANGES[settings.exchange]; if (!ex) return; GM_openInTab(ex.marketUrl(coin), { active: true, insert: true }); }); rows.appendChild(row); } } function setStatus(text) { const el = boxEl?.querySelector("#cctp-status"); if (el) el.textContent = text; } function setTime(ts) { const el = boxEl?.querySelector("#cctp-time"); if (el) el.textContent = (ts ? new Date(ts) : new Date()).toLocaleTimeString(); } function setRow(coin, data) { const rows = boxEl.querySelectorAll(".cctp-row"); let target = null; for (const r of rows) { if (r.dataset.base === coin.base && r.dataset.quote === coin.quote) { target = r; break; } } if (!target) return; const priceEl = target.querySelector(".cctp-price"); const chgEl = target.querySelector(".cctp-chg"); const quote = coin.quote || settings.quote; const prefix = unitPrefix(quote); priceEl.classList.remove("cctp-muted"); priceEl.textContent = `${prefix}${formatPrice(data.last)}`; const parts = []; if (settings.showPct) parts.push(`${formatSigned(data.pct, 2)}%`); if (settings.showAbs) parts.push(`${formatSigned(data.abs, 6)}`); chgEl.classList.remove("cctp-muted", "cctp-up", "cctp-down", "cctp-flat"); chgEl.classList.add(pickClass(data.pct)); chgEl.textContent = parts.length ? parts.join(" · ") : "--"; } function setRowError(coin) { const rows = boxEl.querySelectorAll(".cctp-row"); for (const r of rows) { if (r.dataset.base === coin.base && r.dataset.quote === coin.quote) { const p = r.querySelector(".cctp-price"); const c = r.querySelector(".cctp-chg"); p.textContent = "--"; c.textContent = "--"; p.classList.add("cctp-muted"); c.classList.add("cctp-muted"); } } } // --------------------------------------------------------------------------- // Fallback logic // --------------------------------------------------------------------------- const runtime = { consecutiveFailures: 0, lastSwitchAt: 0, orderIndex: 0 }; function normalizeFallbackOrder() { const order = Array.isArray(settings.autoFallback?.order) ? settings.autoFallback.order.slice() : []; const uniq = []; for (const id of order) if (EXCHANGES[id] && !uniq.includes(id)) uniq.push(id); if (!uniq.includes(settings.exchange) && EXCHANGES[settings.exchange]) uniq.unshift(settings.exchange); for (const id of Object.keys(EXCHANGES)) if (!uniq.includes(id)) uniq.push(id); settings.autoFallback.order = uniq; } function updateOrderIndexByExchange() { const order = settings.autoFallback.order; const idx = order.indexOf(settings.exchange); runtime.orderIndex = idx >= 0 ? idx : 0; } async function maybeFallback() { const af = settings.autoFallback; if (!af?.enabled) return false; normalizeFallbackOrder(); updateOrderIndexByExchange(); const threshold = clamp(Number(af.threshold) || 3, 1, 50); if (runtime.consecutiveFailures < threshold) return false; const cooldown = clamp(Number(af.cooldownMs) || 60_000, 0, 24 * 3600_000); if (now() - runtime.lastSwitchAt < cooldown) return false; const order = af.order; const nextIdx = (runtime.orderIndex + 1) % order.length; const nextEx = order[nextIdx]; if (!EXCHANGES[nextEx] || nextEx === settings.exchange) return false; const prev = settings.exchange; settings.exchange = nextEx; if (nextEx === "OKX") { settings.changeBase.mode = "OKX_sodUtc8"; settings.changeBase.label = "今日(UTC+8)"; } else if (nextEx === "BINANCE") { settings.changeBase.mode = "BINANCE_24h"; settings.changeBase.label = "24h"; } else { settings.changeBase.mode = "COINBASE_24h"; settings.changeBase.label = "24h"; } runtime.lastSwitchAt = now(); runtime.consecutiveFailures = 0; await saveSettings(settings); applyAllNow(false); showToast(`数据源自动切换:${EXCHANGES[prev].name} → ${EXCHANGES[nextEx].name}`, af.toastMs); return true; } // --------------------------------------------------------------------------- // Update loop // --------------------------------------------------------------------------- let timer = null; let inFlight = false; async function updateAll(manual = false) { if (inFlight) return; inFlight = true; const ex = EXCHANGES[settings.exchange]; if (!ex) { inFlight = false; return; } setStatus(manual ? "手动刷新…" : "更新中…"); try { const results = await Promise.allSettled(settings.coins.map((c) => ex.fetch(c, settings))); let anyOk = false; let maxTs = 0; for (let i = 0; i < results.length; i++) { const coin = settings.coins[i]; const r = results[i]; if (r.status === "fulfilled") { anyOk = true; maxTs = Math.max(maxTs, r.value.ts || 0); setRow(coin, r.value); } else { setRowError(coin); } } if (anyOk) { runtime.consecutiveFailures = 0; setStatus("OK"); setTime(maxTs); } else { runtime.consecutiveFailures += 1; setStatus(`失败 x${runtime.consecutiveFailures}`); setTime(); await maybeFallback(); } } catch { runtime.consecutiveFailures += 1; setStatus(`异常 x${runtime.consecutiveFailures}`); setTime(); for (const c of settings.coins) setRowError(c); await maybeFallback(); } finally { inFlight = false; } } function restartTimer() { if (timer) clearInterval(timer); timer = setInterval(() => updateAll(false), clamp(Number(settings.refreshMs) || 5000, 1000, 3600_000)); } // --------------------------------------------------------------------------- // Config panel (no extra UI for autoLogo; auto enabled by default) // --------------------------------------------------------------------------- function openConfigPanel() { const existing = document.getElementById("cctp-config-overlay"); if (existing) { existing.style.display = "flex"; return; } const overlay = document.createElement("div"); overlay.id = "cctp-config-overlay"; const config = document.createElement("div"); config.id = "cctp-config"; config.innerHTML = `

Corner Crypto Ticker Pro · 配置

保存后立即生效;导入导出用于备份/迁移配置。
切换后:API 拉取与“点击跳转”会随之改变。
频率过快可能触发限速/风控。建议 ≥ 3000ms。
例如 ["OKX","BINANCE","COINBASE_EXCHANGE"]
OKX:今日(UTC+8/UTC0) 或 24h;其他交易所:24h。
未填写 logo 时,将尝试自动抓取;填写 logo 则优先使用自定义。
若图标未显示,通常是该币种在图标库中缺失或网络不可达;可在币种 JSON 中填入 logo URL 覆盖。
`; overlay.appendChild(config); document.documentElement.appendChild(overlay); config.querySelector("#cctp-cfg-close").addEventListener("click", () => { overlay.style.display = "none"; }); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.style.display = "none"; }); config.querySelector("#cctp-cfg-exchange").value = settings.exchange; config.querySelector("#cctp-cfg-refresh").value = settings.refreshMs; config.querySelector("#cctp-cfg-shiftDrag").checked = !!settings.drag?.requireShift; config.querySelector("#cctp-cfg-fallbackOn").checked = !!settings.autoFallback?.enabled; config.querySelector("#cctp-cfg-fallbackThreshold").value = settings.autoFallback?.threshold ?? 3; config.querySelector("#cctp-cfg-fallbackCooldown").value = settings.autoFallback?.cooldownMs ?? 60000; config.querySelector("#cctp-cfg-fallbackOrder").value = JSON.stringify(settings.autoFallback?.order ?? ["OKX", "BINANCE", "COINBASE_EXCHANGE"]); config.querySelector("#cctp-cfg-toastMs").value = settings.autoFallback?.toastMs ?? 2200; config.querySelector("#cctp-cfg-showPct").checked = !!settings.showPct; config.querySelector("#cctp-cfg-showAbs").checked = !!settings.showAbs; config.querySelector("#cctp-cfg-up").value = settings.colors.up; config.querySelector("#cctp-cfg-down").value = settings.colors.down; config.querySelector("#cctp-cfg-flat").value = settings.colors.flat; config.querySelector("#cctp-cfg-bg").value = settings.colors.bg; config.querySelector("#cctp-cfg-unitMap").value = JSON.stringify(settings.unitSymbol, null, 2); config.querySelector("#cctp-cfg-coins").value = JSON.stringify(settings.coins, null, 2); config.querySelector("#cctp-cfg-top").value = settings.position?.top ?? 0; config.querySelector("#cctp-cfg-left").value = settings.position?.left ?? 0; config.querySelector("#cctp-cfg-width").value = settings.size?.width ?? 216; config.querySelector("#cctp-cfg-height").value = settings.size?.height ?? 151; const baseSel = config.querySelector("#cctp-cfg-baseMode"); function fillBaseOptions(exchangeId) { baseSel.innerHTML = ""; const opts = exchangeId === "OKX" ? [ { v: "OKX_sodUtc8", t: "今日(UTC+8)(sodUtc8)" }, { v: "OKX_sodUtc0", t: "今日(UTC0)(sodUtc0)" }, { v: "OKX_open24h", t: "24h(open24h)" }, ] : exchangeId === "BINANCE" ? [{ v: "BINANCE_24h", t: "24h(openPrice)" }] : [{ v: "COINBASE_24h", t: "24h(stats.open)" }]; for (const o of opts) { const opt = document.createElement("option"); opt.value = o.v; opt.textContent = o.t; baseSel.appendChild(opt); } } fillBaseOptions(settings.exchange); baseSel.value = settings.changeBase.mode; config.querySelector("#cctp-cfg-exchange").addEventListener("change", (e) => { fillBaseOptions(e.target.value); baseSel.value = e.target.value === "OKX" ? "OKX_sodUtc8" : e.target.value === "BINANCE" ? "BINANCE_24h" : "COINBASE_24h"; }); config.querySelector("#cctp-cfg-export").addEventListener("click", async () => { const txt = JSON.stringify(settings, null, 2); await navigator.clipboard.writeText(txt); alert("已复制当前配置到剪贴板。"); }); config.querySelector("#cctp-cfg-import").addEventListener("click", async () => { const txt = await navigator.clipboard.readText().catch(() => ""); const input = prompt("粘贴配置 JSON(也可先从剪贴板读取后直接确认):", txt || ""); if (!input) return; const obj = safeJsonParse(input); if (!obj) { alert("JSON 解析失败"); return; } settings = deepMerge(DEFAULT_SETTINGS, obj); normalizeFallbackOrder(); updateOrderIndexByExchange(); await saveSettings(settings); applyAllNow(true); alert("已导入并应用。"); }); config.querySelector("#cctp-cfg-save").addEventListener("click", async () => { const next = deepMerge(DEFAULT_SETTINGS, settings); next.exchange = config.querySelector("#cctp-cfg-exchange").value; next.refreshMs = clamp(Number(config.querySelector("#cctp-cfg-refresh").value) || 5000, 1000, 3600_000); next.drag.requireShift = !!config.querySelector("#cctp-cfg-shiftDrag").checked; next.autoFallback.enabled = !!config.querySelector("#cctp-cfg-fallbackOn").checked; next.autoFallback.threshold = clamp(Number(config.querySelector("#cctp-cfg-fallbackThreshold").value) || 3, 1, 50); next.autoFallback.cooldownMs = clamp(Number(config.querySelector("#cctp-cfg-fallbackCooldown").value) || 60000, 0, 24 * 3600_000); next.autoFallback.toastMs = clamp(Number(config.querySelector("#cctp-cfg-toastMs").value) || 2200, 500, 20000); const orderObj = safeJsonParse(config.querySelector("#cctp-cfg-fallbackOrder").value); if (Array.isArray(orderObj) && orderObj.length) next.autoFallback.order = orderObj; next.showPct = !!config.querySelector("#cctp-cfg-showPct").checked; next.showAbs = !!config.querySelector("#cctp-cfg-showAbs").checked; next.changeBase.mode = baseSel.value; next.changeBase.label = (next.changeBase.mode === "OKX_sodUtc8") ? "今日(UTC+8)" : (next.changeBase.mode === "OKX_sodUtc0") ? "今日(UTC0)" : "24h"; next.colors.up = config.querySelector("#cctp-cfg-up").value || DEFAULT_SETTINGS.colors.up; next.colors.down = config.querySelector("#cctp-cfg-down").value || DEFAULT_SETTINGS.colors.down; next.colors.flat = config.querySelector("#cctp-cfg-flat").value || DEFAULT_SETTINGS.colors.flat; next.colors.bg = config.querySelector("#cctp-cfg-bg").value || DEFAULT_SETTINGS.colors.bg; const unitObj = safeJsonParse(config.querySelector("#cctp-cfg-unitMap").value); if (unitObj && typeof unitObj === "object") next.unitSymbol = unitObj; const coinsObj = safeJsonParse(config.querySelector("#cctp-cfg-coins").value); if (Array.isArray(coinsObj) && coinsObj.length) next.coins = coinsObj; const top = Number(config.querySelector("#cctp-cfg-top").value); const left = Number(config.querySelector("#cctp-cfg-left").value); const w = Number(config.querySelector("#cctp-cfg-width").value); const h = Number(config.querySelector("#cctp-cfg-height").value); if (Number.isFinite(top) && Number.isFinite(left)) { next.position.mode = "custom"; next.position.top = Math.round(top); next.position.left = Math.round(left); } if (Number.isFinite(w) && Number.isFinite(h)) { next.size.width = clamp(Math.round(w), 150, 2000); next.size.height = clamp(Math.round(h), 100, 2000); } settings = next; normalizeFallbackOrder(); updateOrderIndexByExchange(); await saveSettings(settings); applyAllNow(true); alert("已保存并应用。"); }); config.querySelector("#cctp-cfg-reset").addEventListener("click", async () => { if (!confirm("确认恢复默认配置?这会覆盖当前所有设置。")) return; settings = deepMerge({}, DEFAULT_SETTINGS); normalizeFallbackOrder(); updateOrderIndexByExchange(); await saveSettings(settings); applyAllNow(true); alert("已恢复默认并应用。"); }); } // --------------------------------------------------------------------------- // Apply / bootstrap // --------------------------------------------------------------------------- let boxEl = null; function applyAllNow(resetFailures) { if (resetFailures) runtime.consecutiveFailures = 0; if (boxEl) boxEl.remove(); boxEl = createBox(); renderRows(boxEl); restartTimer(); updateAll(false); } function registerMenu() { GM_registerMenuCommand("Corner Crypto Ticker Pro · 打开配置", () => openConfigPanel()); GM_registerMenuCommand("Corner Crypto Ticker Pro · 立即刷新", () => updateAll(true)); GM_registerMenuCommand("Corner Crypto Ticker Pro · 切换拖拽(Shift)", async () => { settings.drag.requireShift = !settings.drag.requireShift; await saveSettings(settings); applyAllNow(false); showToast(settings.drag.requireShift ? "拖拽:需按住 Shift" : "拖拽:直接拖拽", 1800); }); GM_registerMenuCommand("Corner Crypto Ticker Pro · 恢复默认", async () => { settings = deepMerge({}, DEFAULT_SETTINGS); normalizeFallbackOrder(); updateOrderIndexByExchange(); await saveSettings(settings); applyAllNow(true); }); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- let settings; (async function init() { settings = await loadSettings(); normalizeFallbackOrder(); updateOrderIndexByExchange(); GM_addStyle(buildCss()); ensureToast(); registerMenu(); boxEl = createBox(); renderRows(boxEl); await saveSettings(settings); restartTimer(); updateAll(false); })(); })();