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

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

// ==UserScript==
// @name         Alpha Board(链上盈利数据展示/底部横排暂时/可隐藏/柔和玻璃)
// @namespace    https://greasyfork.org/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);
  }
})();