Komari探针+Naive主题
Naive主题项目地址
目标效果
①剩余价值显示

②标签位置更换

自定义 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>