Greasy Fork

来自缓存

Greasy Fork is available in English.

Alpha Board(链上盈利数据展示/底部横排暂时/可隐藏/柔和玻璃)

链上实时账户看板 · 默认最小化 · 按模型独立退避 · 轻量玻璃态 UI · 低饱和 P&L · 横排 6 卡片并展示相对更新时间

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Alpha Board(链上盈利数据展示/底部横排暂时/可隐藏/柔和玻璃)
// @namespace    http://greasyfork.icu/zh-CN/users/1211909-amazing-fish
// @version      1.2.6
// @description  链上实时账户看板 · 默认最小化 · 按模型独立退避 · 轻量玻璃态 UI · 低饱和 P&L · 横排 6 卡片并展示相对更新时间
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @connect      api.hyperliquid.xyz
// @connect      api.binance.com
// @connect      data-asg.goldprice.org
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // 仅在顶层窗口注入,并防止重复安装
  let isTopLevel = true;
  try { isTopLevel = window.top === window.self; } catch { isTopLevel = false; }
  if (!isTopLevel) return;

  const globalScope = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  const INSTALL_FLAG = '__alphaBoardInstalled__';
  if (globalScope[INSTALL_FLAG]) return;
  globalScope[INSTALL_FLAG] = true;

  /**
   * Alpha Board 1.2.6
   * ------------------
   *  - 针对多模型地址的链上账户价值聚合看板
   *  - 以 Hyperliquid API 为数据源,独立退避拉取、无本地持久化
   *  - 默认最小化,支持标题点击折叠,卡片横向排列并带相对时间
   *  - 鼠标滚轮上下滑动可驱动卡片横向滑动,并带缓动动画
   *  - 轻量玻璃态视觉 + 低饱和红/绿提示,适合常驻屏幕
   */

  /** ===== 常量与默认(无记忆) ===== */
  const INITIAL_CAPITAL = 10000;     // 账户价值基准,用于计算 PnL
  const FRESH_THRESH_MS = 15000;     // 顶栏“Stale” 阈值
  const JITTER_MS = 250;             // 轮询轻微抖动,避免同时请求
  const BACKOFF_STEPS = [3000, 5000, 8000, 12000]; // 网络失败退避梯度
  const LOCK_RETRY_MS = 700;         // 未抢到共享锁时的重试间隔
  let   COLLAPSED = true;            // 默认以折叠状态启动

  // 默认地址列表:直接在此修改即可,不会弹窗也不写本地存储
  const ADDRS = {
    'GPT-5': '0x67293D914eAFb26878534571add81F6Bd2D9fE06',
    'Gemini 2.5 Pro': '0x1b7A7D099a670256207a30dD0AE13D35f278010f',
    'Claude Sonnet 4.5': '0x59fA085d106541A834017b97060bcBBb0aa82869',
    'Grok-4': '0x56D652e62998251b56C8398FB11fcFe464c08F84',
    'DeepSeek V3.1': '0xC20aC4Dc4188660cBF555448AF52694CA62b0734',
    'Qwen3-Max': '0x7a8fd8bba33e37361ca6b0cb4518a44681bad2f3'
  };

  // 模型清单,用于确定卡片顺序与徽章缩写
  const MODELS = [
    { key: 'GPT-5', badge: 'GPT' },
    { key: 'Gemini 2.5 Pro', badge: 'GEM' },
    { key: 'Claude Sonnet 4.5', badge: 'CLD' },
    { key: 'Grok-4', badge: 'GRK' },
    { key: 'DeepSeek V3.1', badge: 'DSK' },
    { key: 'Qwen3-Max', badge: 'QWN' },
  ];

  const FEATURE_CARDS = [
    {
      key: 'btc',
      badge: 'BTC',
      name: 'BTC · 实时价',
      source: '数据源 Binance',
      fetcher: fetchBtcTicker,
    },
    {
      key: 'xau',
      badge: 'XAU',
      name: '黄金 · 现货价',
      source: '数据源 GoldPrice.org',
      fetcher: fetchGoldPrice,
    },
  ];

  const FEATURE_REFRESH_MS = 6000;

  const VISIBLE_CARD_COUNT = 4;
  const WIDTH_EXTRA_PX = 80;
  const DOM_DELTA_LINE = 1;
  const DOM_DELTA_PAGE = 2;
  const WHEEL_LINE_HEIGHT = 16;
  const WHEEL_ANIM_MIN_MS = 160;
  const WHEEL_ANIM_MAX_MS = 420;
  const WHEEL_ANIM_PX_RATIO = 0.45;
  const ACTIVATION_KEYS = new Set(['Enter', ' ']);

  let cancelWheelAnimation = ()=>{};

  const mqlReducedMotion = globalScope.matchMedia ? globalScope.matchMedia('(prefers-reduced-motion: reduce)') : null;
  let REDUCED_MOTION = !!(mqlReducedMotion && mqlReducedMotion.matches);
  if (mqlReducedMotion) {
    const handleMotionChange = (ev)=>{
      const next = !!(ev.matches ?? ev.currentTarget?.matches);
      REDUCED_MOTION = next;
      if (next) cancelWheelAnimation();
    };
    if (typeof mqlReducedMotion.addEventListener === 'function') {
      mqlReducedMotion.addEventListener('change', handleMotionChange);
    } else if (typeof mqlReducedMotion.addListener === 'function') {
      mqlReducedMotion.addListener(handleMotionChange);
    }
  }

  /** ===== 玻璃态 + 透明度优化样式(更透、更克制) ===== */
  // 所有视觉样式集中在一处,方便微调颜色、透明度或布局。
  GM_addStyle(`
    #ab-dock {
      position: fixed; left: 12px; bottom: 12px; z-index: 2147483647;
      pointer-events: none;
      font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI",
                   Roboto,"PingFang SC","Microsoft YaHei","Noto Sans CJK SC", Arial;
      color-scheme: dark;
      --gap: 7px; --radius: 14px;
      --pY: 6px; --pX: 10px; --icon: 28px;
      --ab-target-width: calc(4 * 168px + 3 * var(--gap) + 24px + ${WIDTH_EXTRA_PX}px);
      --fsName: 9.5px; --fsVal: 12.5px; --fsSub: 9.5px;

      /* ↓↓↓ 更低存在感的玻璃态(降低 blur / saturate / 亮度) ↓↓↓ */
      --bg: rgba(12,14,18,0.26);
      --bg2: rgba(12,14,18,0.12);
      --card: rgba(18,21,28,0.28);
      --card-hover: rgba(26,30,38,0.38);
      --brd: rgba(255,255,255,0.10);
      --soft: rgba(255,255,255,0.08);
      --shadow: 0 12px 30px rgba(0,0,0,0.2);

      /* ↓↓↓ 低饱和柔和绿/红(P&L + 状态点 + 闪烁) ↓↓↓ */
      --green: rgb(204,255,216);
      --red:   rgb(255,215,213);
      --blue:  #60a5fa;
      --text:  #e6e8ee;
    }

    /* 展开按钮:更透、轻玻璃 */
    #ab-toggle {
      pointer-events: auto;
      display: inline-flex;
      align-items:center; gap:6px;
      padding:5px 9px; border-radius:11px;
      background: rgba(18,21,28,0.24);
      border:1px solid rgba(255,255,255,0.10); color:var(--text); font-weight:600; font-size:11px; letter-spacing:.3px;
      box-shadow: 0 6px 16px rgba(0,0,0,0.22);
      cursor: pointer; user-select: none;
      backdrop-filter: saturate(0.75) blur(3px);
      transition: background .2s ease, border-color .2s ease, transform .15s ease;
    }
    #ab-toggle:hover { background: rgba(22,25,34,0.32); border-color: rgba(255,255,255,0.16); transform: translateY(-1px); }

    /* 面板主体:更透、少 blur、少 saturate */
    #ab-wrap {
      pointer-events: auto;
      display: none;
      background:
        linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.008)) ,
        radial-gradient(140% 160% at 0% 100%, rgba(96,165,250,0.05), transparent 60%) ,
        var(--bg);
      border: 1px solid rgba(255,255,255,0.09);
      border-radius: 16px;
      padding: 6px 10px 8px;
      box-shadow: 0 14px 30px rgba(0,0,0,0.24);
      width: min(96vw, var(--ab-target-width));
      max-width: min(96vw, var(--ab-target-width));
      backdrop-filter: saturate(0.75) blur(3px);
      overflow: visible;
    }

    #ab-dock.ab-expanded #ab-toggle { display: none; }
    #ab-dock.ab-expanded #ab-wrap { display: block; }
    #ab-dock.ab-collapsed #ab-toggle { display: inline-flex; }
    #ab-dock.ab-collapsed #ab-wrap { display: none; }

    #ab-topbar { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; margin-bottom:4px; padding:0; width:100%; gap:8px; }
    #ab-left { display:flex; align-items:center; gap:8px; min-width:0; }
    #ab-center { display:flex; align-items:center; justify-content:center; }
    #ab-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
    #ab-title { color:#f7faff; font-size:11px; font-weight:700; letter-spacing:.35px; cursor: pointer; text-transform: uppercase; text-shadow: 0 0 8px rgba(0,0,0,0.35); }
    #ab-status { display:flex; align-items:center; gap:5px; font-size:10.5px; color:#f0f4ff; letter-spacing:.25px; text-shadow: 0 0 8px rgba(0,0,0,0.32); font-weight:500; line-height:1; white-space:nowrap; }
    .ab-dot { width:8px; height:8px; border-radius:50%; background:#9ca3af; }
    .ab-live  { background: var(--green); box-shadow: 0 0 10px color-mix(in srgb, var(--green) 35%, transparent); }
    .ab-warn  { background: #f59e0b;   box-shadow: 0 0 10px rgba(245,158,11,0.30); }
    .ab-dead  { background: var(--red); box-shadow: 0 0 10px color-mix(in srgb, var(--red) 35%, transparent); }

    #ab-link {
      pointer-events: auto;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 26px;
      height: 26px;
      border-radius: 8px;
      color: #f5f7ff;
      text-decoration: none;
      background: rgba(255,255,255,0.05);
      border: 1px solid transparent;
      transition: background .2s ease, border-color .2s ease, transform .15s ease;
    }
    #ab-link:hover {
      background: rgba(255,255,255,0.10);
      border-color: rgba(255,255,255,0.12);
      transform: translateY(-1px);
    }
    #ab-link svg { width: 14px; height: 14px; fill: currentColor; }

    #ab-expand-btn {
      pointer-events: auto;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 26px;
      height: 26px;
      border-radius: 8px;
      background: transparent;
      border: 1px solid transparent;
      color: #f5f7ff;
      font-size: 13px;
      font-weight: 600;
      line-height: 1;
      cursor: pointer;
      backdrop-filter: none;
      box-shadow: none;
      transition: transform .15s ease, color .2s ease;
    }
    #ab-expand-btn:hover {
      color: #ffffff;
      transform: translateY(-1px);
    }
    #ab-expand-btn:active { transform: scale(0.95); }
    #ab-expand-btn:focus-visible {
      outline: 2px solid rgba(96,165,250,0.45);
      outline-offset: 2px;
    }
    #ab-expand-btn svg {
      width: 12px;
      height: 12px;
      stroke: currentColor;
      stroke-width: 1.6;
      fill: none;
      stroke-linecap: round;
      stroke-linejoin: round;
      transition: transform .2s ease;
    }
    #ab-expand-btn.expanded svg {
      transform: rotate(180deg);
    }

    /* 横向一行 + 滚动 */
    #ab-row-viewport {
      position: relative;
      overflow-x: auto;
      overflow-y: hidden;
      scrollbar-width: thin;
      scrollbar-color: transparent transparent;
      width: 100%;
      max-width: min(96vw, var(--ab-target-width));
      padding: 0 10px 8px 10px;
      margin: 0;
    }
    #ab-row-viewport::-webkit-scrollbar { height: 4px; }
    #ab-row-viewport::-webkit-scrollbar-thumb { background: transparent; border-radius: 999px; }
    #ab-row-viewport:hover,
    #ab-row-viewport:focus-within {
      scrollbar-color: rgba(255,255,255,0.16) transparent;
    }
    #ab-row-viewport:hover::-webkit-scrollbar-thumb,
    #ab-row-viewport:focus-within::-webkit-scrollbar-thumb {
      background: rgba(255,255,255,0.16);
    }

    #ab-row {
      display:flex;
      flex-wrap: nowrap;
      gap: var(--gap);
      padding-right: 4px;
      transition: opacity .2s ease;
    }

    .ab-card {
      flex: 0 0 auto;
      min-width: 152px; max-width: 208px;
      position: relative; display:flex; align-items:flex-start; gap:8px;
      padding: var(--pY) var(--pX);
      background: linear-gradient(155deg, rgba(255,255,255,0.05), rgba(255,255,255,0));
      border: 1px solid rgba(255,255,255,0.08);
      border-radius: var(--radius);
      transition: transform 220ms ease, box-shadow 220ms ease, background 160ms ease, border-color 160ms ease;
      will-change: transform;
      --hover-lift: 0px;
      --flip-translate-x: 0px;
      --flip-translate-y: 0px;
      --card-shadow: 0 0 0 0 rgba(0,0,0,0);
      --flash-shadow: 0 0 0 0 rgba(0,0,0,0);
      transform: translate(var(--flip-translate-x, 0px), var(--flip-translate-y, 0px)) translateY(var(--hover-lift, 0px));
      box-shadow: var(--card-shadow), var(--flash-shadow);
    }
    .ab-card:hover {
      background: linear-gradient(155deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02));
      border-color: rgba(255,255,255,0.16);
      --card-shadow: 0 10px 24px rgba(0,0,0,0.26);
      --hover-lift: -1px;
    }

    .ab-icon {
      width: var(--icon); height: var(--icon);
      border-radius: 8px; display:grid; place-items:center;
      font-weight:700; font-size:9.5px; letter-spacing:.45px; color:#10131a;
      background: rgba(248,251,255,0.58);
      border: 1px solid rgba(255,255,255,0.28); user-select:none; cursor: pointer;
      box-shadow: 0 6px 16px rgba(0,0,0,0.22);
      backdrop-filter: blur(6px) saturate(1.1);
      transition: background 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
    }
    .ab-icon:hover { background: rgba(255,255,255,0.82); border-color: rgba(255,255,255,0.42); box-shadow: 0 10px 20px rgba(0,0,0,0.28); }
    .ab-icon:active { transform: scale(0.96); }
    .ab-body { display:flex; flex-direction:column; gap:3px; min-width:0; }
    .ab-head { display:flex; align-items:center; justify-content:space-between; gap:6px; }
    .ab-name { font-size: var(--fsName); color:#f7faff; font-weight:600; letter-spacing:.22px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow: 0 0 6px rgba(0,0,0,0.32); }
    .ab-time { font-size:9.5px; color:#eef3ff; letter-spacing:.22px; white-space:nowrap; font-weight:500; text-shadow: 0 0 6px rgba(0,0,0,0.30); }
    .ab-val  { font-size: var(--fsVal);  color:#f9fbff; font-weight:700; letter-spacing:.26px; font-variant-numeric: tabular-nums; text-shadow: 0 0 6px rgba(0,0,0,0.28); }
    .ab-sub  { font-size: var(--fsSub);  color:#a4afc0; font-variant-numeric: tabular-nums; letter-spacing:.18px; }

    /* ↓ P&L 低饱和绿/红 */
    .ab-sub .pos { color: color-mix(in srgb, var(--green) 82%, #d1fae5); }
    .ab-sub .neg { color: color-mix(in srgb, var(--red) 82%,   #fee2e2); }

    /* 涨跌闪烁(进一步降低透明度与冲击感) */
    @media (prefers-reduced-motion: no-preference) {
      .flash-up   { --flash-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--green) 18%, transparent); }
      .flash-down { --flash-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--red)   18%, transparent); }
    }

    #ab-feature-cards {
      display: none;
      flex-wrap: wrap;
      gap: var(--gap);
      width: 100%;
      padding: 6px 10px 0 10px;
      pointer-events: none;
    }
    #ab-feature-cards .ab-card {
      min-width: 168px;
      pointer-events: auto;
    }
    #ab-feature-cards .ab-icon { cursor: default; }
    #ab-dock.ab-feature-open #ab-row-viewport {
      overflow: hidden;
      padding-bottom: 0;
      scrollbar-width: none;
    }
    #ab-dock.ab-feature-open #ab-row-viewport::-webkit-scrollbar { display: none; }
    #ab-dock.ab-feature-open #ab-feature-cards {
      display: flex;
      pointer-events: auto;
    }
    #ab-dock.ab-feature-open #ab-row {
      opacity: 0;
      pointer-events: none;
      display: none;
    }

    /* 骨架占位 */
    .skeleton {
      background: linear-gradient(90deg, rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.12) 45%, rgba(255,255,255,0.05) 65%);
      background-size: 400% 100%;
      animation: ab-shimmer 1.2s ease-in-out infinite;
      border-radius: 999px; height: 8px; width: 104px; opacity: .6;
    }
    @keyframes ab-shimmer {
      0% { background-position: 100% 0; }
      100% { background-position: -100% 0; }
    }

    /* Toast */
    #ab-toast {
      position: absolute; left: 8px; bottom: 100%; margin-bottom: 8px;
      background: rgba(0,0,0,0.78); color:#fff; padding:6px 8px; border-radius:8px;
      font-size:11px; pointer-events:none; opacity:0; transform: translateY(6px);
      transition: opacity .2s ease, transform .2s ease;
    }
    #ab-toast.show { opacity:1; transform: translateY(0); }
  `);

  /** ===== DOM ===== */
  // 创建挂载点与初始骨架,配合 toggle/title 控制展示状态。
  const dock = document.createElement('div');
  dock.id = 'ab-dock';
  dock.innerHTML = `
    <div id="ab-toggle" title="展开 Alpha Board">Alpha Board</div>
    <div id="ab-wrap" role="region" aria-label="Alpha Board 实时看板">
      <div id="ab-topbar">
        <div id="ab-left">
          <span id="ab-title" title="点击最小化">Alpha Board · 链上实时</span>
          <div id="ab-status" aria-live="polite">
            <span class="ab-dot" id="ab-dot"></span>
            <span id="ab-time">Syncing…</span>
          </div>
        </div>
        <div id="ab-center">
          <button
            id="ab-expand-btn"
            type="button"
            aria-label="展开扩展内容"
            aria-expanded="false"
            title="展开扩展内容"
          >
            <svg viewBox="0 0 16 16" aria-hidden="true">
              <path d="M4.25 6.25L8 10l3.75-3.75" />
            </svg>
          </button>
        </div>
        <div id="ab-right">
          <a
            id="ab-link"
            href="https://nof1.ai"
            target="_blank"
            rel="noopener noreferrer"
            aria-label="打开 Nof1.ai(新窗口)"
            title="打开 Nof1.ai(新窗口)"
          >
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="M5 4a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 112 0v3a4 4 0 01-4 4H5a4 4 0 01-4-4V6a4 4 0 014-4h3a1 1 0 110 2H5z" />
              <path d="M9 3a1 1 0 011-1h7a1 1 0 011 1v7a1 1 0 11-2 0V5.414l-8.293 8.293a1 1 0 11-1.414-1.414L14.586 4H10a1 1 0 01-1-1z" />
            </svg>
          </a>
        </div>
      </div>
      <div id="ab-row-viewport">
        <div id="ab-row"></div>
        <div
          id="ab-feature-cards"
          role="region"
          aria-label="Alpha Board 扩展内容"
          aria-hidden="true"
        ></div>
      </div>
      <div id="ab-toast" role="status" aria-live="polite"></div>
    </div>
  `;
  document.documentElement.appendChild(dock);

  const wrap       = dock.querySelector('#ab-wrap');
  const viewport   = dock.querySelector('#ab-row-viewport');
  const row        = dock.querySelector('#ab-row');
  const toggle     = dock.querySelector('#ab-toggle');
  const title      = dock.querySelector('#ab-title');
  const expandBtn  = dock.querySelector('#ab-expand-btn');
  const featureCardsContainer = dock.querySelector('#ab-feature-cards');
  const dot        = dock.querySelector('#ab-dot');
  const timeEl     = dock.querySelector('#ab-time');
  const toast      = dock.querySelector('#ab-toast');

  const featureCardsByKey = new Map();
  const featureState = new Map();
  const featureTimeDisplays = new Map();
  const featureLastValueMap = new Map();
  const featureMetaByKey = new Map();

  if (featureCardsContainer) {
    FEATURE_CARDS.forEach((item) => {
      const card = document.createElement('div');
      card.className = 'ab-card ab-feature-card';
      card.setAttribute('data-key', item.key);
      card.innerHTML = `
        <div class="ab-icon" aria-hidden="true">${item.badge}</div>
        <div class="ab-body">
          <div class="ab-head">
            <div class="ab-name" title="${item.name}">${item.name}</div>
            <div class="ab-time">等待数据</div>
          </div>
          <div class="ab-val"><span class="skeleton" style="width:120px;"></span></div>
          <div class="ab-sub">${item.source || ''}</div>
        </div>
      `;
      featureCardsContainer.appendChild(card);
      featureCardsByKey.set(item.key, card);
      featureTimeDisplays.set(item.key, card.querySelector('.ab-time'));
      featureState.set(item.key, { price: null, change: null, percent: null, ts: 0 });
      featureMetaByKey.set(item.key, item);
    });
  }

  // 展开/收起(默认最小化)
  toggle.setAttribute('role', 'button');
  toggle.setAttribute('aria-controls', 'ab-wrap');
  toggle.setAttribute('tabindex', '0');
  title.setAttribute('role', 'button');
  title.setAttribute('tabindex', '0');
  title.setAttribute('aria-controls', 'ab-wrap');

  function applyCollapseState(){
    if (COLLAPSED) {
      dock.classList.add('ab-collapsed');
      dock.classList.remove('ab-expanded');
      toggle.setAttribute('aria-hidden', 'false');
      toggle.setAttribute('aria-expanded', 'false');
      title.setAttribute('aria-expanded', 'false');
      wrap.setAttribute('aria-hidden', 'true');
    } else {
      dock.classList.add('ab-expanded');
      dock.classList.remove('ab-collapsed');
      toggle.setAttribute('aria-hidden', 'true');
      toggle.setAttribute('aria-expanded', 'true');
      title.setAttribute('aria-expanded', 'true');
      wrap.setAttribute('aria-hidden', 'false');
    }
  }
  function minimize(){ COLLAPSED = true;  applyCollapseState(); }
  function expand()  { COLLAPSED = false; applyCollapseState(); scheduleWidthSync(); }
  let FEATURE_EXPANDED = false;
  function setFeatureState(next){
    const nextExpanded = !!next;
    if (viewport) {
      if (nextExpanded) {
        const rect = viewport.getBoundingClientRect();
        const measured = rect.height || viewport.scrollHeight;
        if (measured) {
          viewport.style.minHeight = `${measured}px`;
        } else {
          viewport.style.removeProperty('min-height');
        }
      } else {
        viewport.style.removeProperty('min-height');
      }
    }
    FEATURE_EXPANDED = nextExpanded;
    dock.classList.toggle('ab-feature-open', FEATURE_EXPANDED);
    if (expandBtn) {
      const label = FEATURE_EXPANDED ? '收起扩展内容' : '展开扩展内容';
      expandBtn.setAttribute('aria-label', label);
      expandBtn.setAttribute('title', label);
      expandBtn.setAttribute('aria-expanded', FEATURE_EXPANDED ? 'true' : 'false');
      expandBtn.classList.toggle('expanded', FEATURE_EXPANDED);
    }
    if (featureCardsContainer) {
      featureCardsContainer.setAttribute('aria-hidden', FEATURE_EXPANDED ? 'false' : 'true');
    }
  }
  function toggleFeature(){ setFeatureState(!FEATURE_EXPANDED); }
  function attachPressHandlers(el, handler){
    el.addEventListener('click', handler);
    const tagName = (el.tagName || '').toLowerCase();
    if (tagName === 'button') return;
    el.addEventListener('keydown', (ev)=>{
      if (!ACTIVATION_KEYS.has(ev.key)) return;
      ev.preventDefault();
      handler(ev);
    });
  }
  attachPressHandlers(toggle, expand);
  attachPressHandlers(title, minimize);
  if (expandBtn) attachPressHandlers(expandBtn, toggleFeature);
  setFeatureState(false);
  minimize();

  let widthSyncPending = false;
  let lastWidthApplied = 0;
  function scheduleWidthSync(){
    if (widthSyncPending) return;
    widthSyncPending = true;
    requestAnimationFrame(()=>{
      widthSyncPending = false;
      applyWidthSync();
    });
  }
  function applyWidthSync(){
    const cards = Array.from(row.querySelectorAll('.ab-card'));
    if (!cards.length) return;

    const sampleCount = Math.min(cards.length, VISIBLE_CARD_COUNT);
    let totalWidth = 0;
    let measured = 0;

    for (let i = 0; i < sampleCount; i += 1) {
      const rect = cards[i].getBoundingClientRect();
      if (!rect.width) continue;
      totalWidth += rect.width;
      measured += 1;
    }

    if (!measured) return;

    const rowStyles = getComputedStyle(row);
    const gapValue = parseFloat(rowStyles.gap || rowStyles.columnGap || '0') || 0;
    const rowPadL = parseFloat(rowStyles.paddingLeft || '0') || 0;
    const rowPadR = parseFloat(rowStyles.paddingRight || '0') || 0;

    const viewportStyles = getComputedStyle(viewport);
    const viewportPadL = parseFloat(viewportStyles.paddingLeft || '0') || 0;
    const viewportPadR = parseFloat(viewportStyles.paddingRight || '0') || 0;

    const visibleGapTotal = gapValue * Math.max(0, measured - 1);
    const baseWidth = totalWidth
      + visibleGapTotal
      + rowPadL + rowPadR
      + viewportPadL + viewportPadR;

    const contentWidth = baseWidth + WIDTH_EXTRA_PX;

    const maxWidthPx = Math.min(window.innerWidth * 0.96, contentWidth);
    if (Math.abs(maxWidthPx - lastWidthApplied) < 0.5) return;
    lastWidthApplied = maxWidthPx;
    dock.style.setProperty('--ab-target-width', `${maxWidthPx}px`);
  }
  window.addEventListener('resize', scheduleWidthSync, { passive: true });
  viewport.addEventListener('wheel', handleViewportWheel, { passive: false });

  let wheelAnimId = 0;
  let wheelAnimStart = 0;
  let wheelAnimFrom = 0;
  let wheelAnimTo = 0;
  let wheelAnimDuration = WHEEL_ANIM_MIN_MS;
  let wheelAnimTarget = null;

  cancelWheelAnimation = ()=>{
    if (!wheelAnimTarget) return;
    if (wheelAnimId) cancelAnimationFrame(wheelAnimId);
    wheelAnimTarget.scrollLeft = wheelAnimTo;
    wheelAnimId = 0;
    wheelAnimTarget = null;
  };

  function easeOutCubic(t){
    return 1 - Math.pow(1 - t, 3);
  }

  function beginWheelAnimation(target, to, distance){
    if (REDUCED_MOTION) {
      cancelWheelAnimation();
      target.scrollLeft = to;
      return;
    }
    wheelAnimTarget = target;
    wheelAnimFrom = target.scrollLeft;
    wheelAnimTo = to;
    wheelAnimDuration = Math.min(
      WHEEL_ANIM_MAX_MS,
      Math.max(WHEEL_ANIM_MIN_MS, WHEEL_ANIM_MIN_MS + distance * WHEEL_ANIM_PX_RATIO)
    );
    wheelAnimStart = performance.now();
    if (!wheelAnimId) {
      wheelAnimId = requestAnimationFrame(stepWheelAnimation);
    }
  }

  function stepWheelAnimation(now){
    const target = wheelAnimTarget;
    if (!target) {
      wheelAnimId = 0;
      return;
    }

    const duration = wheelAnimDuration;
    if (duration <= 0) {
      target.scrollLeft = wheelAnimTo;
      wheelAnimId = 0;
      wheelAnimTarget = null;
      return;
    }

    const progress = Math.min(1, (now - wheelAnimStart) / duration);
    const eased = easeOutCubic(progress);
    const next = wheelAnimFrom + (wheelAnimTo - wheelAnimFrom) * eased;
    target.scrollLeft = next;

    if (progress < 1 && Math.abs(wheelAnimTo - next) > 0.01) {
      wheelAnimId = requestAnimationFrame(stepWheelAnimation);
    } else {
      target.scrollLeft = wheelAnimTo;
      wheelAnimId = 0;
      wheelAnimTarget = null;
    }
  }

  function handleViewportWheel(ev){
    if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
    const target = ev.currentTarget;
    if (!(target instanceof HTMLElement)) return;
    const maxScrollLeft = target.scrollWidth - target.clientWidth;
    if (maxScrollLeft <= 0) return;

    const primaryDelta = Math.abs(ev.deltaY) >= Math.abs(ev.deltaX) ? ev.deltaY : 0;
    if (!primaryDelta) return;

    let deltaPx = primaryDelta;
    if (ev.deltaMode === DOM_DELTA_LINE) deltaPx *= WHEEL_LINE_HEIGHT;
    else if (ev.deltaMode === DOM_DELTA_PAGE) deltaPx *= target.clientWidth;

    if (!deltaPx) return;

    const prev = target.scrollLeft;
    const next = Math.min(maxScrollLeft, Math.max(0, prev + deltaPx));
    if (Math.abs(next - prev) < 0.01) return;

    beginWheelAnimation(target, next, Math.abs(next - prev));
    ev.preventDefault();
  }

  /** ===== 状态与卡片 ===== */
  const state = new Map();              // key -> { value, addr, addrCanon, ts }
  const cardsByKey = new Map();         // key -> card DOM 节点
  const timeDisplays = new Map();       // key -> 时间显示 DOM
  let   lastOrder = MODELS.map(m=>m.key); // 保留历史顺序以便未来做最小化动画
  let   lastGlobalSuccess = 0;
  let   seenAnySuccess = false;
  const lastValueMap = new Map();       // 涨跌闪烁使用
  const addrSubscribers = new Map();    // canon addr -> Set<modelKey>

  MODELS.forEach((m) => {
    const card = document.createElement('div');
    card.className = 'ab-card';
    card.setAttribute('data-key', m.key);
    card.innerHTML = `
      <div class="ab-icon" title="点击复制地址">${m.badge}</div>
      <div class="ab-body">
        <div class="ab-head">
          <div class="ab-name" title="${m.key}">${m.key}</div>
          <div class="ab-time"><span class="skeleton" style="width:48px;"></span></div>
        </div>
        <div class="ab-val"><span class="skeleton"></span></div>
        <div class="ab-sub"><span class="skeleton" style="width:80px;"></span></div>
      </div>
    `;
    row.appendChild(card);
    cardsByKey.set(m.key, card);

    // 初始状态:为每张卡片记住地址和时间显示节点
    const addr = ADDRRSafe(ADDRS[m.key]);
    const canon = canonAddress(addr);
    state.set(m.key, { value: null, addr, addrCanon: canon, ts: 0 });
    timeDisplays.set(m.key, card.querySelector('.ab-time'));

    if (canon) {
      if (!addrSubscribers.has(canon)) addrSubscribers.set(canon, new Set());
      addrSubscribers.get(canon).add(m.key);
    }

    // 复制地址
    card.querySelector('.ab-icon').addEventListener('click', async ()=>{
      const addr = state.get(m.key).addr;
      if (!addr) { showToast('未配置地址'); return; }
      try {
        if (typeof GM_setClipboard === 'function') GM_setClipboard(addr);
        else await navigator.clipboard.writeText(addr);
        showToast('地址已复制');
      } catch { showToast('复制失败'); }
    });
  });

  scheduleWidthSync();
  refreshCardTimes();

  /** ===== 网络层 ===== */
  const storage = (()=>{ try { return globalScope.localStorage; } catch { return null; } })();
  const STORAGE_OK = !!storage;
  const TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
  const CACHE_PREFIX = '__ab_cache__';
  const LOCK_PREFIX  = '__ab_lock__';
  const CACHE_TTL_MS = 2500;
  const LOCK_TIMEOUT_MS = 15000;
  const CHANNEL_NAME = 'alpha-board-net-sync';
  const bc = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL_NAME) : null;
  const sharedResultCache = new Map(); // canon addr -> { value, ts, success }
  const heldLocks = new Map(); // storage key -> token
  const sleep = (ms)=>new Promise(resolve=>setTimeout(resolve, ms));

  if (bc) {
    bc.addEventListener('message', (ev)=>{
      const data = ev.data;
      if (!data || data.type !== 'ab-result') return;
      if (data.origin === TAB_ID) return;
      if (typeof data.addr !== 'string') return;
      handleSharedResult(data.addr, data.payload);
    });
  }

  if (STORAGE_OK) {
    globalScope.addEventListener('storage', (ev)=>{
      if (!ev.key || !ev.newValue) return;
      if (ev.key.startsWith(CACHE_PREFIX)) {
        const addr = ev.key.slice(CACHE_PREFIX.length);
        const payload = safeParseJSON(ev.newValue);
        handleSharedResult(addr, payload);
      }
    });

    globalScope.addEventListener('unload', ()=>{
      heldLocks.forEach((token, key)=>{
        try {
          const current = safeParseJSON(storage.getItem(key));
          if (!current || current.owner === TAB_ID) storage.removeItem(key);
        } catch {}
      });
      heldLocks.clear();
    });
  }

  /**
   * 以 GM_xmlhttpRequest POST JSON,统一处理超时/异常。
   * @param {string} url
   * @param {object} data
   * @returns {Promise<any>}
   */
  function gmPostJson(url, data) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'POST', url, data: JSON.stringify(data),
        headers: { 'Content-Type': 'application/json' },
        timeout: 10000,
        onload: (res) => {
          try { resolve(JSON.parse(res.responseText)); }
          catch (e) { reject(e); }
        },
        onerror: reject, ontimeout: reject
      });
    });
  }

  function gmGetJson(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url,
        timeout: 10000,
        onload: (res) => {
          try { resolve(JSON.parse(res.responseText)); }
          catch (e) { reject(e); }
        },
        onerror: reject, ontimeout: reject
      });
    });
  }

  /**
   * 拉取地址的账户价值,优先读取逐仓/全仓字段,异常时返回 null。
   * @param {string} address
   * @returns {Promise<number|null>}
   */
  async function fetchAccountValue(address) {
    if (!address || !/^0x[a-fA-F0-9]{40}$/i.test(address)) return null;
    try {
      const resp = await gmPostJson('https://api.hyperliquid.xyz/info', {
        type: 'clearinghouseState', user: address, dex: ''
      });
      const v = resp?.marginSummary?.accountValue || resp?.crossMarginSummary?.accountValue;
      const num = v ? parseFloat(v) : NaN;
      return Number.isFinite(num) ? num : null;
    } catch { return null; }
  }

  async function fetchBtcTicker(){
    try {
      const resp = await gmGetJson('https://api.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT');
      const priceRaw = resp?.lastPrice ?? resp?.weightedAvgPrice ?? resp?.price;
      const changeRaw = resp?.priceChange;
      const pctRaw = resp?.priceChangePercent;
      const price = priceRaw == null ? NaN : parseFloat(priceRaw);
      if (!Number.isFinite(price)) return null;
      const change = changeRaw == null ? NaN : parseFloat(changeRaw);
      const percent = pctRaw == null ? NaN : parseFloat(pctRaw);
      return {
        price,
        change: Number.isFinite(change) ? change : null,
        percent: Number.isFinite(percent) ? percent / 100 : null,
        ts: Date.now(),
      };
    } catch { return null; }
  }

  async function fetchGoldPrice(){
    try {
      const resp = await gmGetJson('https://data-asg.goldprice.org/dbXRates/USD');
      const items = Array.isArray(resp?.items) ? resp.items : [];
      const usd = items.find((item)=> item && item.curr === 'USD') || items[0];
      const priceRaw = usd?.xauPrice;
      const changeRaw = usd?.chgXau;
      const pctRaw = usd?.pcXau;
      const price = priceRaw == null ? NaN : parseFloat(priceRaw);
      if (!Number.isFinite(price)) return null;
      const change = changeRaw == null ? NaN : parseFloat(changeRaw);
      const percent = pctRaw == null ? NaN : parseFloat(pctRaw);
      const tsRaw = resp?.tsj ?? resp?.ts;
      const ts = tsRaw == null ? NaN : Number(tsRaw);
      return {
        price,
        change: Number.isFinite(change) ? change : null,
        percent: Number.isFinite(percent) ? percent / 100 : null,
        ts: Number.isFinite(ts) ? ts : Date.now(),
      };
    } catch { return null; }
  }

  function tryUseSharedResult(canon, rec){
    if (!canon) return false;
    const payload = getFreshSharedResult(canon);
    if (!payload) return false;
    if (payload.success) {
      rec.step = 0;
    } else {
      rec.step = Math.min(rec.step + 1, BACKOFF_STEPS.length - 1);
    }
    handleSharedResult(canon, payload);
    return true;
  }

  function getFreshSharedResult(canon){
    if (!canon) return null;
    const now = Date.now();
    const cached = sharedResultCache.get(canon);
    if (cached && (now - cached.ts) <= CACHE_TTL_MS) return cached;
    if (STORAGE_OK) {
      const stored = readCache(canon);
      if (stored && (now - stored.ts) <= CACHE_TTL_MS) return stored;
    }
    return null;
  }

  async function tryAcquireLock(canon){
    if (!STORAGE_OK || !canon) return false;
    const key = LOCK_PREFIX + canon;
    const token = `${TAB_ID}:${Math.random().toString(36).slice(2, 10)}`;
    let attempt = 0;
    while (attempt < 4) {
      attempt += 1;
      const now = Date.now();
      try {
        const current = safeParseJSON(storage.getItem(key));
        if (current && typeof current.ts === 'number' && typeof current.owner === 'string') {
          if (current.owner !== TAB_ID && (now - current.ts) < LOCK_TIMEOUT_MS) return false;
        }
        const payload = JSON.stringify({ owner: TAB_ID, ts: now, token });
        storage.setItem(key, payload);
        await sleep(0);
        const verify = safeParseJSON(storage.getItem(key));
        if (verify && verify.owner === TAB_ID && verify.token === token) {
          heldLocks.set(key, token);
          return true;
        }
      } catch {
        return false;
      }
      await sleep(5 * attempt);
    }
    return false;
  }

  function releaseLock(canon){
    if (!STORAGE_OK || !canon) return;
    const key = LOCK_PREFIX + canon;
    try {
      const current = safeParseJSON(storage.getItem(key));
      const token = heldLocks.get(key);
      if (!current || current.owner !== TAB_ID) {
        if (!current) storage.removeItem(key);
      } else if (!current.token || !token || current.token === token) {
        storage.removeItem(key);
      }
    } catch { storage?.removeItem?.(key); }
    heldLocks.delete(key);
  }

  function readCache(canon){
    if (!STORAGE_OK || !canon) return null;
    try {
      return safeParseJSON(storage.getItem(CACHE_PREFIX + canon));
    } catch { return null; }
  }

  function writeCache(canon, payload){
    if (!STORAGE_OK || !canon) return;
    try {
      storage.setItem(CACHE_PREFIX + canon, JSON.stringify(payload));
    } catch {}
  }

  function shareResult(canon, payload){
    if (!canon || !payload) return;
    if (STORAGE_OK) writeCache(canon, payload);
    if (bc) {
      try { bc.postMessage({ type: 'ab-result', addr: canon, payload, origin: TAB_ID }); } catch {}
    }
    handleSharedResult(canon, payload);
  }

  function handleSharedResult(canon, payload){
    if (!canon || !payload || typeof payload.ts !== 'number') return;
    const prev = sharedResultCache.get(canon);
    if (prev && prev.ts >= payload.ts) return;
    sharedResultCache.set(canon, payload);

    if (payload.success) {
      seenAnySuccess = true;
      lastGlobalSuccess = Math.max(lastGlobalSuccess, payload.ts);
      const keys = addrSubscribers.get(canon);
      if (keys) {
        keys.forEach(key=>{
          if (state.has(key)) updateCard(key, payload.value, payload.ts);
        });
      }
      updateStatus();
    }
  }

  /** ===== 按模型独立轮询 + 失败退避 ===== */
  const pollers = new Map(); // key -> { step, timer }

  /**
   * 为指定模型启动独立轮询:成功时重置退避,失败时升级退避。
   * @param {string} mkey
   */
  function startPoller(mkey){
    const rec = { step: 0, timer: null };
    pollers.set(mkey, rec);

    const run = async () => {
      const s = state.get(mkey);
      const addr = s.addr;
      const canon = s.addrCanon || canonAddress(addr);
      if (!s.addrCanon) s.addrCanon = canon;

      // 无地址时:视为“不可用”,降频到最高 12s
      if (!addr) {
        updateCard(mkey, null);
        rec.step = BACKOFF_STEPS.length - 1;
        scheduleNext();
        return;
      }

      if (tryUseSharedResult(canon, rec)) {
        scheduleNext();
        return;
      }

      let acquired = false;
      if (STORAGE_OK && canon) {
        acquired = await tryAcquireLock(canon);
        if (!acquired) {
          scheduleNext(LOCK_RETRY_MS);
          return;
        }
      }

      try {
        const val = await fetchAccountValue(addr);
        const nowTs = Date.now();
        if (val == null) {
          rec.step = Math.min(rec.step + 1, BACKOFF_STEPS.length - 1);
          if (canon) shareResult(canon, { value: null, ts: nowTs, success: false });
        } else {
          rec.step = 0;
          if (canon) {
            shareResult(canon, { value: val, ts: nowTs, success: true });
          } else {
            seenAnySuccess = true;
            lastGlobalSuccess = nowTs;
            updateCard(mkey, val, nowTs);
            updateStatus();
          }
        }
        scheduleNext();
      } finally {
        if (acquired && canon) releaseLock(canon);
      }
    };

    function scheduleNext(customDelay){
      const base = typeof customDelay === 'number' ? customDelay : BACKOFF_STEPS[rec.step];
      const jitter = typeof customDelay === 'number' ? 0 : (Math.random() * 2 - 1) * JITTER_MS;
      clearTimeout(rec.timer);
      rec.timer = setTimeout(run, Math.max(0, base + jitter));
    }

    scheduleNext();
  }

  // 为所有模型启动独立轮询
  MODELS.forEach(m => startPoller(m.key));

  function startFeatureTickerPollers(){
    FEATURE_CARDS.forEach((card)=>{
      const { key, fetcher } = card;
      if (!featureCardsByKey.has(key) || typeof fetcher !== 'function') return;

      let timer = null;

      const run = async ()=>{
        try {
          const data = await fetcher();
          if (data) {
            updateFeatureCard(key, data);
          } else {
            const hadValue = !!(featureState.get(key)?.price != null);
            updateFeatureCard(key, null, hadValue);
          }
        } catch {
          const hadValue = !!(featureState.get(key)?.price != null);
          updateFeatureCard(key, null, hadValue);
        } finally {
          scheduleNext();
        }
      };

      const scheduleNext = ()=>{
        clearTimeout(timer);
        timer = setTimeout(run, FEATURE_REFRESH_MS);
      };

      run();
    });
  }

  startFeatureTickerPollers();

  /** ===== 渲染 ===== */
  /**
   * 更新单个模型卡片的文案、排序及动画效果。
   * @param {string} mkey
   * @param {number|null} value
   */
  function updateCard(mkey, value, tsOverride){
    const s = state.get(mkey);
    s.value = value;

    // 先记录旧位置信息(用于 FLIP 动画)
    const firstRects = new Map();
    MODELS.forEach(m=>{
      const el = cardsByKey.get(m.key);
      firstRects.set(m.key, el.getBoundingClientRect());
    });

    // 更新本卡展示
    const el = cardsByKey.get(mkey);
    const valEl = el.querySelector('.ab-val');
    const subEl = el.querySelector('.ab-sub');
    if (value == null) {
      valEl.innerHTML = '<span class="skeleton" style="width:120px;"></span>';
      subEl.textContent = s.addr ? '等待数据…' : '地址未配置';
      s.ts = 0;
    } else {
      const prev = lastValueMap.get(mkey);
      valEl.textContent = fmtUSD(value);
      const pnl = value - INITIAL_CAPITAL;
      const pct = pnl / INITIAL_CAPITAL;
      subEl.innerHTML = `PnL <span class="${pnl>=0?'pos':'neg'}">${fmtUSD(pnl)} · ${fmtPct(pct)}</span>`;
      s.ts = typeof tsOverride === 'number' ? tsOverride : Date.now();

      // 涨跌闪烁(更柔和)
      if (typeof prev === 'number' && prev !== value) {
        el.classList.remove('flash-up','flash-down');
        void el.offsetWidth;
        el.classList.add(prev < value ? 'flash-up' : 'flash-down');
        setTimeout(()=>el.classList.remove('flash-up','flash-down'), 260);
      }
      lastValueMap.set(mkey, value);
    }

    // 重排:按最新值排序(不显示名次,仅内部排序)
    const items = MODELS.map(m => ({ key: m.key, value: state.get(m.key).value }));
    items.sort((a,b)=>(b.value??-Infinity)-(a.value??-Infinity));
    const newOrder = items.map(i=>i.key);

    const els = items.map(i=>cardsByKey.get(i.key));
    const lastRects = new Map();
    els.forEach(el=>{
      const key = el.getAttribute('data-key');
      lastRects.set(key, firstRects.get(key));
    });
    els.forEach((el)=> row.appendChild(el));

    els.forEach(el=>{
      const key = el.getAttribute('data-key');
      const first = lastRects.get(key);
      const last  = el.getBoundingClientRect();
      if (first) {
        const dx = first.left - last.left;
        const dy = first.top  - last.top;
        if (dx || dy) {
          el.style.transition = 'none';
          el.style.setProperty('--flip-translate-x', `${dx}px`);
          el.style.setProperty('--flip-translate-y', `${dy}px`);
          el.getBoundingClientRect();
          el.style.transition = 'transform 240ms ease';
          el.style.setProperty('--flip-translate-x', '0px');
          el.style.setProperty('--flip-translate-y', '0px');
          el.addEventListener('transitionend', ()=>{ el.style.transition=''; }, { once:true });
        }
      }
    });

    lastOrder = newOrder;
    refreshCardTimes();
    scheduleWidthSync();
  }

  function updateFeatureCard(key, payload, errored){
    const card = featureCardsByKey.get(key);
    if (!card) return;

    let s = featureState.get(key);
    if (!s) {
      s = { price: null, change: null, percent: null, ts: 0 };
      featureState.set(key, s);
    }

    const meta = featureMetaByKey.get(key) || {};
    const valEl = card.querySelector('.ab-val');
    const subEl = card.querySelector('.ab-sub');

    if (!payload || payload.price == null) {
      if (s.price == null) {
        valEl.innerHTML = '<span class="skeleton" style="width:120px;"></span>';
        subEl.textContent = errored ? '获取失败,请稍后' : (meta.source || '等待数据…');
        s.ts = 0;
      }
      return;
    }

    const nowTs = payload.ts || Date.now();
    const prev = featureLastValueMap.get(key);
    valEl.textContent = fmtUSD(payload.price);
    const change = typeof payload.change === 'number' ? payload.change : null;
    const percent = typeof payload.percent === 'number' ? payload.percent : null;

    if (change != null && percent != null) {
      subEl.innerHTML = `24h <span class="${change>=0?'pos':'neg'}">${fmtUSDWithSign(change)} · ${fmtPct(percent)}</span>`;
    } else if (change != null) {
      subEl.innerHTML = `24h <span class="${change>=0?'pos':'neg'}">${fmtUSDWithSign(change)}</span>`;
    } else if (percent != null) {
      subEl.innerHTML = `24h <span class="${percent>=0?'pos':'neg'}">${fmtPct(percent)}</span>`;
    } else if (meta.source) {
      subEl.textContent = meta.source;
    } else {
      subEl.textContent = '最新行情';
    }

    if (typeof prev === 'number' && prev !== payload.price) {
      card.classList.remove('flash-up','flash-down');
      void card.offsetWidth;
      card.classList.add(prev < payload.price ? 'flash-up' : 'flash-down');
      setTimeout(()=>card.classList.remove('flash-up','flash-down'), 260);
    }
    featureLastValueMap.set(key, payload.price);

    s.price = payload.price;
    s.change = change;
    s.percent = percent;
    s.ts = nowTs;
    refreshCardTimes();
  }

  /** ===== 顶栏状态:Live / Stale / Dead ===== */
  /**
   * 刷新顶栏状态点及文字,反映最新网络健康情况。
   */
  function updateStatus(){
    const now = Date.now();
    if (!seenAnySuccess) {
      dot.className = 'ab-dot ab-dead';
      timeEl.textContent = 'No data';
      return;
    }
    const stale = (now - lastGlobalSuccess) > FRESH_THRESH_MS;
    dot.className = 'ab-dot ' + (stale ? 'ab-warn' : 'ab-live');
    timeEl.textContent = (stale ? 'Stale' : ('更新 ' + fmtTime(now)));
  }
  /**
   * 刷新卡片上的相对时间显示。
   */
  function refreshCardTimes(){
    const now = Date.now();
    timeDisplays.forEach((el, key)=>{
      if (!el) return;
      const s = state.get(key);
      if (!s) return;
      if (!s.addr) { el.textContent = '未配置'; return; }
      if (!s.ts) { el.textContent = '等待数据'; return; }
      el.textContent = fmtSince(s.ts, now);
    });
    featureTimeDisplays.forEach((el, key)=>{
      if (!el) return;
      const s = featureState.get(key);
      if (!s || !s.ts) { el.textContent = '等待数据'; return; }
      el.textContent = fmtSince(s.ts, now);
    });
  }
  // 轻量 UI 刷新:仅更新文本与状态点,不追加网络请求
  setInterval(()=>{ updateStatus(); refreshCardTimes(); }, 1000);

  /** ===== 工具函数 ===== */
  function canonAddress(addr){ return typeof addr === 'string' ? addr.trim().toLowerCase() : ''; }
  function safeParseJSON(str){ try { return str ? JSON.parse(str) : null; } catch { return null; } }
  /** 清洗地址字符串,避免 undefined/null */
  function ADDRRSafe(addr) { return typeof addr === 'string' ? addr.trim() : ''; }
  /** 统一格式化 USD 文案 */
  function fmtUSD(n){ return n==null ? '—' : '$' + n.toLocaleString(undefined,{maximumFractionDigits:2}); }
  function fmtUSDWithSign(n){
    if (n == null) return '—';
    const absFmt = fmtUSD(Math.abs(n));
    return (n >= 0 ? '+' : '-') + absFmt.slice(1);
  }
  /** 输出带正负号的百分比 */
  function fmtPct(n){ return n==null ? '—' : ((n>=0?'+':'') + (n*100).toFixed(2) + '%'); }
  /**
   * 根据时间戳生成中文相对时间。
   * @param {number} ts
   * @param {number} [now]
   */
  function fmtSince(ts, now = Date.now()){
    const diff = Math.max(0, now - ts);
    if (diff < 5000) return '刚刚';
    if (diff < 60000) return Math.floor(diff/1000) + ' 秒前';
    if (diff < 3600000) return Math.floor(diff/60000) + ' 分钟前';
    if (diff < 86400000) return Math.floor(diff/3600000) + ' 小时前';
    return Math.floor(diff/86400000) + ' 天前';
  }
  /** HH:MM:SS 形式的绝对时间 */
  function fmtTime(ts){
    const d=new Date(ts); const p=n=>n<10?'0'+n:n;
    return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
  }
  /**
   * 统一的轻量提示气泡。
   * @param {string} msg
   */
  function showToast(msg){
    toast.textContent = msg;
    toast.classList.add('show');
    clearTimeout(showToast._t);
    showToast._t = setTimeout(()=>toast.classList.remove('show'), 1200);
  }
})();