Komari探针+Naive主题

Setang 195 阅读 信息分享

Naive主题项目地址

Click here

目标效果

①剩余价值显示

②标签位置更换

自定义 Body

<style>
/* ============================================================
   Komari Custom Body — v2.0
   ============================================================ */
.n-tabs .n-tabs-pane-wrapper {
    overflow: unset;
}
.text-xs:not(.flex-col) .n-text ~ .n-text,
.n-card :is(.n-progress, .traffic-progress) ~ .n-text {
  zoom: 0.88;
}
.n-progress .n-progress-graph .n-progress-graph-line .n-progress-graph-line-rail,
.traffic-progress .traffic-progress__rail {
  height: 4px !important;
}
.n-card {
  overflow: hidden;
}
.n-card .flex-between:last-child {
  transition: 300ms;
}
.n-card .flex-between:last-child .n-text:last-child {
  line-height: 1;
  box-shadow: 0 0 0 1px rgb(111 108 236 / 30%) inset;
  border-radius: 2px;
  padding: 0 7px;
  font-size: 12px;
  height: 22px;
  background: rgb(111 108 236 / 10%);
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgb(111 108 236);
}
.n-card .n-card-header__extra .n-button ~ .n-tag{
    font-size: 0;
    border-radius: 9rem;
    height: 8px;
    width: 8px;
    padding: 0;
    overflow: unset !important;
    background-color: var(--n-text-color);
}
.n-card .n-card-header__extra .n-button ~ .n-tag::after{
    content: '';
    position: absolute;
    inset: 0;
    background: currentColor;
    border-radius: 9rem;
    height: 16px;
    width: 16px;
    opacity: 0.2;
    flex-shrink: 0;
    margin: -4px;
}
.n-card .shrink-0.n-icon ~ .flex.shrink-0 {
  position: absolute;
  backdrop-filter: blur(8px);
  padding: 1rem 22px;
  left: 1px;
  right: 1px;
  bottom: 1px;
  z-index: 9;
  transform: translateY(10%);
  opacity: 0;
  transition: 300ms;
}
.n-card:hover .shrink-0.n-icon ~ .flex.shrink-0 {
  transform: translateY(0);
  opacity: 1;
}
.n-card:hover .flex-between:last-child {
  opacity: 0.3;
}
.echarts > div:last-child{
    background: rgba(255, 255, 255, 0.6) !important;
    box-shadow: 0 0 15px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.06) !important;
}

/* 汇总卡片公共样式 */
.komari-summary-card {
  margin: 0 0 12px 0;
  padding: 16px 20px;
  border-radius: 3px;
  background: #fff;
  border: 1px solid rgb(239, 239, 245);
  box-shadow: 0 1px 2px -2px rgba(0,0,0,.08), 0 3px 6px 0 rgba(0,0,0,.06);
  display: flex;
  align-items: center;
  gap: 24px;
  flex-wrap: wrap;
}
.komari-summary-card .kmc-title {
  font-size: 13px;
  color: rgb(118, 124, 130);
  white-space: nowrap;
  margin-bottom: 2px;
}
.komari-summary-card .kmc-total {
  font-size: 22px;
  font-weight: bold;
  color: rgb(31, 34, 37);
  font-family: "TCloud Number VF", "MiSans VF", sans-serif;
  white-space: nowrap;
}
.komari-summary-card .kmc-divider {
  width: 1px;
  height: 36px;
  background: rgb(239, 239, 245);
  flex-shrink: 0;
}
.komari-summary-card .kmc-breakdown {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  flex: 1;
  align-items: center;
}
.komari-summary-card .kmc-item-blue {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  background: rgba(32, 128, 240, 0.08);
  color: rgb(32, 128, 240);
  border: 1px solid rgba(32, 128, 240, 0.2);
  white-space: nowrap;
  font-family: "TCloud Number VF", "MiSans VF", sans-serif;
}
.komari-summary-card .kmc-item-red {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  background: rgba(208, 48, 80, 0.08);
  color: rgb(208, 48, 80);
  border: 1px solid rgba(208, 48, 80, 0.2);
  white-space: nowrap;
  font-family: "TCloud Number VF", "MiSans VF", sans-serif;
}
.komari-summary-card .kmc-meta {
  font-size: 11px;
  color: rgb(180, 184, 190);
  white-space: nowrap;
  margin-left: auto;
  text-align: right;
  line-height: 1.8;
}
</style>

<script>
/* ============================================================
   Komari Cost Summary — v2.0
   修复:DOM 锚点查找更健壮、汇率接口失败兜底、Tab 切换更可靠
   ============================================================ */
(function () {
  const VERSION = '2.0';

  const SYMBOL_TO_CODE = {
    '$':   'USD', '€':  'EUR',
    '¥':   'CNY', 'CN¥':'CNY', 'RMB':'CNY',
    'JP¥': 'JPY', 'JPY':'JPY',
    'HK$': 'HKD', 'CA$':'CAD', 'C$':'CAD', '£':'GBP',
  };

  // 硬编码兜底汇率(CNY 基准,定期可手动更新)
  const FALLBACK_RATES = { CNY:1, USD:7.25, EUR:7.85, HKD:0.928, CAD:5.32, GBP:9.18, JPY:0.0478 };

  let allNodes   = null;
  let ratesData  = null;
  let lastTabKey = null;   // tabName + nodeCount 组合,避免误判
  let observer   = null;
  let injectLock = false;

  // ── 汇率获取,带超时 + 兜底 ────────────────────────────────
  async function fetchRates() {
    try {
      const ctrl = new AbortController();
      const tid  = setTimeout(() => ctrl.abort(), 5000);
      const res  = await fetch(
        'https://api.frankfurter.app/latest?from=CNY&to=USD,EUR,HKD,CAD,GBP,JPY',
        { signal: ctrl.signal }
      );
      clearTimeout(tid);
      const json  = await res.json();
      const rates = { CNY: 1 };
      for (const [code, val] of Object.entries(json.rates)) {
        rates[code] = 1 / val;
      }
      console.log(`[komari-cost v${VERSION}] 汇率加载成功`);
      return rates;
    } catch (e) {
      console.warn(`[komari-cost v${VERSION}] 汇率接口失败,使用兜底汇率`, e);
      return FALLBACK_RATES;
    }
  }

  // ── 节点数据 ───────────────────────────────────────────────
  async function fetchNodes() {
    const res  = await fetch('/api/nodes');
    const json = await res.json();
    if (json.status !== 'success') throw new Error('nodes API 失败');
    return json.data || [];
  }

  function toCNY(amount, symbol, rates) {
    const code = SYMBOL_TO_CODE[(symbol || '$').trim()] || 'USD';
    return amount * (rates[code] || FALLBACK_RATES[code] || 7.25);
  }

  // ── 获取当前激活 Tab 名 ────────────────────────────────────
  // 兼容 NaiveUI 不同版本的 class 写法
  function getActiveTabName() {
    const sel = [
      '.n-tabs-tab--active',
      '.n-tabs-tab.n-tabs-tab--active',
      '[class*="tabs-tab"][class*="active"]',
    ];
    for (const s of sel) {
      const el = document.querySelector(s);
      if (el) return el.textContent.trim();
    }
    return '全部节点';
  }

  // ── 找插入锚点(多重 fallback)─────────────────────────────
  function findInsertTarget() {
    // 策略1:找可见 tab-pane 内的 grid
    const panes = document.querySelectorAll('.n-tab-pane');
    for (const pane of panes) {
      if (window.getComputedStyle(pane).display === 'none') continue;
      const grid = pane.querySelector('.grid');
      if (grid) return grid;
    }

    // 策略2:从第一张 node-card 向上找 display:grid 的祖先
    const firstCard = document.querySelector('.node-card');
    if (firstCard) {
      let el = firstCard.parentElement;
      for (let i = 0; i < 8; i++) {
        if (!el) break;
        if (window.getComputedStyle(el).display === 'grid') return el;
        el = el.parentElement;
      }
      // 策略3:直接用 node-card 的父节点
      return firstCard.parentElement;
    }

    return null;
  }

  // ── 计算月费 ───────────────────────────────────────────────
  function calcMonthly(nodes) {
    let totalCNY = 0;
    const grouped = {};
    for (const node of nodes) {
      if (!node.price || node.price <= 0) continue;
      const symbol  = (node.currency || '$').trim();
      const code    = SYMBOL_TO_CODE[symbol] || 'USD';
      const cycle   = node.billing_cycle || 30;
      const monthly = (node.price / cycle) * 30;
      const cny     = toCNY(monthly, symbol, ratesData);
      totalCNY += cny;
      if (!grouped[code]) grouped[code] = { symbol, monthly: 0, cny: 0 };
      grouped[code].monthly += monthly;
      grouped[code].cny     += cny;
    }
    return { totalCNY, grouped };
  }

  // ── 计算剩余价值 ───────────────────────────────────────────
  function calcResidual(nodes) {
    let totalCNY = 0;
    const expiringSoon = [];
    let noExpiry = 0;
    const now = Date.now();
    for (const node of nodes) {
      if (!node.price || node.price <= 0) continue;
      const expiredAt = node.expired_at || '';
      if (!expiredAt || expiredAt.startsWith('0001')) { noExpiry++; continue; }
      const expireTs = new Date(expiredAt).getTime();
      if (isNaN(expireTs) || expireTs <= now) continue;
      const remainDays = (expireTs - now) / 86400000;
      const cycle      = node.billing_cycle || 30;
      const cny        = toCNY((node.price / cycle) * remainDays, node.currency || '$', ratesData);
      totalCNY += cny;
      if (remainDays <= 7) expiringSoon.push({ name: node.name, days: Math.ceil(remainDays) });
    }
    return { totalCNY, expiringSoon, noExpiry };
  }

  // ── 构建卡片 DOM ───────────────────────────────────────────
  function buildMonthlyCard(nodes, updateTime, tabName) {
    const { totalCNY, grouped } = calcMonthly(nodes);
    const isAll  = !tabName || tabName === '全部节点';
    const label  = isAll ? '每月服务器花销' : `每月花销 · ${tabName}`;
    const bHTML  = Object.entries(grouped).map(([, info]) =>
      `<span class="kmc-item-blue">${info.symbol}${info.monthly.toFixed(2)} ≈ ¥${info.cny.toFixed(2)}</span>`
    ).join('');
    const card = document.createElement('div');
    card.id        = 'komari-card-monthly';
    card.className = 'komari-summary-card';
    card.innerHTML = `
      <div>
        <div class="kmc-title">💸 ${label}</div>
        <div class="kmc-total">¥${totalCNY.toFixed(2)} / 月</div>
      </div>
      <div class="kmc-divider"></div>
      <div class="kmc-breakdown">${bHTML || '<span style="font-size:13px;color:rgb(180,184,190);">暂无价格数据</span>'}</div>
      <div class="kmc-meta">汇率更新: ${updateTime}</div>
    `;
    return card;
  }

  function buildResidualCard(nodes, tabName) {
    const { totalCNY, expiringSoon, noExpiry } = calcResidual(nodes);
    const isAll  = !tabName || tabName === '全部节点';
    const label  = isAll ? '所有机器剩余价值' : `剩余价值 · ${tabName}`;
    const wHTML  = expiringSoon.map(n =>
      `<span class="kmc-item-red">⚠️ ${n.name} 剩${n.days}天</span>`
    ).join('');
    const card = document.createElement('div');
    card.id        = 'komari-card-residual';
    card.className = 'komari-summary-card';
    card.innerHTML = `
      <div>
        <div class="kmc-title">💎 ${label}</div>
        <div class="kmc-total">¥${totalCNY.toFixed(2)}</div>
      </div>
      <div class="kmc-divider"></div>
      <div class="kmc-breakdown">
        ${expiringSoon.length > 0 ? wHTML : '<span style="font-size:13px;color:rgb(118,124,130);">无即将到期的机器 ✓</span>'}
      </div>
      ${noExpiry > 0 ? `<div class="kmc-meta">${noExpiry} 台未设到期时间</div>` : ''}
    `;
    return card;
  }

  // ── 过滤节点 ───────────────────────────────────────────────
  function getFilteredNodes(tabName) {
    if (!allNodes) return [];
    if (!tabName || tabName === '全部节点') return allNodes;
    return allNodes.filter(n => (n.group || '').trim() === tabName);
  }

  // ── 注入卡片 ───────────────────────────────────────────────
  function injectCards(force) {
    if (!allNodes || !ratesData) return;
    if (injectLock) return;

    const target  = findInsertTarget();
    if (!target) return;

    const tabName  = getActiveTabName();
    const filtered = getFilteredNodes(tabName);
    const tabKey   = `${tabName}::${filtered.length}`;

    // 卡片完好且 tab 未变则跳过
    if (!force &&
        tabKey === lastTabKey &&
        document.getElementById('komari-card-monthly') &&
        document.getElementById('komari-card-residual')) return;

    injectLock = true;
    lastTabKey = tabKey;

    ['komari-card-monthly', 'komari-card-residual'].forEach(id =>
      document.getElementById(id)?.remove()
    );

    const now      = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
    const mCard    = buildMonthlyCard(filtered, now, tabName);
    const rCard    = buildResidualCard(filtered, tabName);

    target.parentNode.insertBefore(rCard, target);
    target.parentNode.insertBefore(mCard, rCard);

    injectLock = false;
  }

  // ── MutationObserver 监听 DOM + Tab 切换 ──────────────────
  function startObserver() {
    if (observer) return;
    observer = new MutationObserver(() => {
      if (!document.querySelector('.node-card')) return;
      const tabName  = getActiveTabName();
      const filtered = getFilteredNodes(tabName);
      const tabKey   = `${tabName}::${filtered.length}`;
      const cardsGone = !document.getElementById('komari-card-monthly');
      if (cardsGone || tabKey !== lastTabKey) {
        requestAnimationFrame(() => injectCards(false));
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // ── 等待元素出现 ───────────────────────────────────────────
  function waitFor(fn, timeout = 15000) {
    return new Promise((resolve, reject) => {
      const start = Date.now();
      const tick  = () => {
        if (fn()) return resolve();
        if (Date.now() - start > timeout) return reject(new Error('waitFor timeout'));
        setTimeout(tick, 200);
      };
      tick();
    });
  }

  // ── 入口 ──────────────────────────────────────────────────
  async function init() {
    try {
      await waitFor(() => !!document.querySelector('.node-card'));
      const [nodes, rates] = await Promise.all([fetchNodes(), fetchRates()]);
      allNodes  = nodes;
      ratesData = rates;
      injectCards(true);
      startObserver();
      console.log(`[komari-cost v${VERSION}] 初始化完成,共 ${nodes.length} 台节点`);
    } catch (e) {
      console.warn(`[komari-cost v${VERSION}] 初始化失败`, e);
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
</script>